Admin access

Administrators manage everything that is not owned by a regular user or organization: system clients and audiences, global roles, users, organizations, LDAP visibility. Most of it is done through the admin GraphQL API; the account dashboard covers the parts that overlap with self-service (clients, audiences, organizations).

Who is an admin

An admin is a user holding the built-in role role:admin ("Admin"). The server makes sure it exists on every start and gives it one scope: admin:graphql on the server's own audience (the audience whose identifier is the issuer URL, https://auth.infra.delta.rocks/).

Holding the role is not enough on its own. Admin rights belong to tokens: only an access token that carries admin:graphql is treated as an admin. Your normal dashboard session is an ordinary user session, even if you hold the role. There are two ways to get an admin token, described below.

Two ways in

GraphiQL

Open https://auth.infra.delta.rocks/graphiql (a GET /graphql redirects there).

  1. The page starts a sign-in with the built-in "GraphiQL" client. If you already have a session you are sent right back, otherwise you sign in as usual. The first time, you confirm the "GraphQL admin" permission.

  2. The GraphiQL client always requires the admin:graphql scope. If your account has no role that grants it, the authorization fails and the toolbar shows the error with a "Sign in again" button.

  3. After sign-in the console loads the schema and is ready.

The console has a query editor, a variables editor (JSON) and a response pane. "Run" (or Ctrl+Enter) runs the operation under the cursor, "Schema" shows the full SDL. Query and variables are kept in the browser's local storage; the token is kept in memory only, so a reload signs in again.

Note
The GraphiQL token carries only admin:graphql, not the account scope. The me and myOrganizations roots answer "account scope required" there. Everything else, including manage, works.

Admin mode in the dashboard

The account header shows "Enter admin mode" when you hold a role that grants admin:graphql (me.canEnterAdminMode). Clicking it:

  1. ends your current sign-in session and asks you to sign in again (prompt=login), so a passkey or password is required even if you were signed in a second ago;

  2. requests openid admin:graphql for the dashboard's built-in "Account" client, so the new token carries both account and admin:graphql;

  3. returns you to the page you were on, with an "Admin mode" badge and an "Exit admin mode" button in the header.

If you cancel the sign-in the dashboard shows "Admin mode was cancelled"; if the account can't get the scope it shows "Admin mode is not available for this account". In both cases the dashboard keeps using your normal token.

"Exit admin mode" revokes the admin token (me.revokeCurrentToken) and signs you in again without the scope. Admin mode also ends on its own when the token expires (access tokens live one hour); the next page load signs you in normally.

pykrete Dashboard (/account) Browser pykrete Dashboard (/account) Browser POST /account/admin-mode (enter) redirect to /authorize, scope "openid admin:graphql", prompt=login sign in again redirect to /account/callback with code /account/callback exchange code at the token endpoint access token (account + admin:graphql) token cookie, back to the original page

What changes while admin mode is on:

  • Every client, audience, organization and organization role is visible and editable, and the escalation guards described in Self-service do not apply.

  • Client settings pages get a "Mark as verified" / "Remove verification" button and an "All organizations (first-party)" section.

  • Dashboard lists show your own clients, audiences and organizations. Open any other one by its URL, for example https://auth.infra.delta.rocks/account/clients/<client id>, https://auth.infra.delta.rocks/account/audiences/<audience id> or https://auth.infra.delta.rocks/account/organizations/<slug>. The admin API lists all of them: openid { clients { clientId name } }, openid { audiences { audienceId name } }, orgs { organizations { id slug name } }. Organizations you are not a member of show an "admin view" badge.

  • Global roles, users and LDAP settings are managed through the admin GraphQL API.

The admin GraphQL API

Endpoint: POST https://auth.infra.delta.rocks/graphql with Authorization: Bearer <access token>. An unknown or expired token gets HTTP 401; a token with neither admin:graphql nor account gets 403.

Mutations mirror the query tree: you navigate to an object and call a mutation on it, inside a mutation operation. Read fields are available inside mutations too.

mutation {
  openid {
    client(clientId: "C...") {
      setAllowedGrantTypes(grantTypes: [AUTHORIZATION_CODE, REFRESH_TOKEN])
    }
  }
}

In the schema, object types that contain mutations appear twice: once without mutation fields for queries (OpenidRoot, Client) and once with a Mut suffix for mutations (OpenidRootMut, ClientMut).

Root Needs Covers

openid

admin

All clients and audiences (including system-owned ones), their redirect URLs, post-logout URLs, grant types, allowed and required scopes, client secrets and subject settings; audience scopes; global roles and the scopes they grant.

userdata

admin

Users: list and look up (by id or username), create, grant and revoke global roles, issue registration codes.

orgs

admin

All organizations: create, update (including private metadata), delete, members and their roles, invitations; which organizations a client may see, client organization usage, access requests; user metadata.

ldap

admin

Which roles a client can see over LDAP, app passwords. See LDAP.

manage

account or admin

The self-service API behind the dashboard (organizations, clients, audiences, roles). With an admin token, ownership checks are bypassed and admin-only mutations (setVerified, setAllOrganizations, setOrganizationAssignable, organizations(all: true)) are allowed.

me

account

The signed-in user's own account, see Account dashboard.

myOrganizations

account

The signed-in user's memberships and per-application organization access.

Calling an admin root without admin:graphql fails with the error "admin:graphql scope required".

Recipes

Ids in the examples are placeholders: users look like U..., clients C..., audiences A..., organizations O.... Organizations can be referenced by id or slug wherever an argument is called organization.

Create a user

There is no self sign-up: accounts are created by admins. Create the user, then issue a one-time registration code:

mutation {
  userdata {
    createUser(username: "alice", name: "Alice Example", email: "alice@example.com") {
      userId
    }
  }
}
mutation {
  userdata {
    user(userId: "U...") {
      issueRegistrationCode
    }
  }
}

Send the code to the user over a trusted channel. On their first sign-in they enter the username, then the "Secret code" and a new password. Afterwards they can add passkeys from the dashboard. The email is stored unverified; the user can verify it under Emails.

issueRegistrationCode refuses users that already have any sign-in method; it is meant for new accounts only.

To let a user back in after they lost their authenticator app, YubiKey and recovery codes, remove all their second factors:

mutation {
  userdata {
    user(userId: "U...") {
      removeSecondFactors
    }
  }
}

It returns how many credentials were removed. The user then signs in with the password alone and can set up a new second factor.

To look users up:

query {
  userdata {
    users(usernames: ["alice"]) {
      userId
      username
      emails { email emailVerified }
      roles { userRoleId name }
    }
  }
}

Create a client

This creates a system-owned client (no user or organization owner). System-owned clients can only be changed by admins.

mutation {
  openid {
    createClient(
      name: "Wiki"
      description: "Team wiki"
      clientKind: CONFIDENTIAL
      usesClientSecret: true
      usesPkce: true
    ) {
      clientId
    }
  }
}

Then configure redirect URLs, grant types and a secret on the new client:

mutation {
  openid {
    client(clientId: "C...") {
      setRedirectUrls(urls: [
        { url: "https://wiki.example.com/oauth/callback", public: true }
      ]) {
        redirectUrl
        public
      }
      setPostLogoutRedirectUrls(urls: ["https://wiki.example.com/"]) {
        redirectUrl
      }
      setAllowedGrantTypes(grantTypes: [AUTHORIZATION_CODE, REFRESH_TOKEN])
      resetClientSecret
    }
  }
}
  • All set... mutations replace the whole list.

  • Redirect URLs must be http or https; query and fragment are dropped. public: true marks the default URL used when a request has no redirect_uri; keep at most one public URL.

  • Grant types: AUTHORIZATION_CODE, REFRESH_TOKEN, DEVICE_CODE. IMPLICIT is rejected. The client credentials grant (CLIENT_CREDENTIALS) is not available yet.

  • resetClientSecret returns a new DELTA-CLIENT-SECRET-... value and invalidates the old one. There is no field to read a secret back, so store it now.

  • Public clients (clientKind: PUBLIC, usesClientSecret: false) use PKCE only.

Finally mark it verified (see Mark an app verified). A system-owned, verified client is "first-party": the consent screen shows no publisher line and it can't be deleted from the dashboard.

Tip
Creating the client from the dashboard (/account/clients, "New client") is quicker, but the client is then owned by your own user account, not by the system. Dashboard-created clients start with the authorization code and refresh token grants and PKCE on.

Endpoint and flow details for the application side are in OpenID Connect.

Create an audience (API) and its scopes

mutation {
  openid {
    createAudience(
      name: "Wiki API"
      description: "Wiki REST API"
      audienceIdentifier: "https://wiki.example.com/api"
      signatureType: RS256
    ) {
      audienceId
    }
  }
}
mutation {
  openid {
    audience(audienceId: "A...") {
      read: declareScope(scopeId: "wiki:read", name: "Read the wiki", description: "Read pages") {
        scopeId
      }
      write: declareScope(scopeId: "wiki:write", name: "Edit the wiki", description: "Create and edit pages") {
        scopeId
      }
    }
  }
}

declareScope also updates name and description of an existing scope; removeScope(scopeId:) deletes one. Clients pass the identifier as the audience parameter. Use RS256 or ES256 as signatureType; other algorithms are not supported.

Scope ids are at most 128 printable ASCII characters without spaces, double quotes or backslashes, not a built-in scope name (openid, profile, email, offline_access, created_at, organizations) and don't start with app_key:.

Allow the scopes on a client (this replaces the client's allowed list; setRequiredScopes works the same way for scopes that are always added):

mutation {
  openid {
    client(clientId: "C...") {
      setAllowedScopes(scopes: [
        { audienceId: "A...", scopeId: "wiki:read" }
        { audienceId: "A...", scopeId: "wiki:write" }
      ]) {
        audienceId
        scopeId
      }
    }
  }
}
Note
Allowing a scope on the client is only half of it. A user only receives an audience scope if one of their roles grants it; otherwise the authorization fails with invalid_scope. Continue with the next recipe. See Scopes and claims for the full rules.

Create a role and grant it to a user

mutation {
  openid {
    createRole(
      userRoleId: "role:wiki-editor"
      name: "Wiki editor"
      description: "Can read and edit the wiki"
    ) {
      userRoleId
    }
  }
}

The role id is free-form; the built-in roles use a role: prefix. Attach scopes (replaces the role's list):

mutation {
  openid {
    role(userRoleId: "role:wiki-editor") {
      setScopes(scopes: [
        { audienceId: "A...", scopeId: "wiki:read" }
        { audienceId: "A...", scopeId: "wiki:write" }
      ]) {
        audienceId
        scopeId
      }
    }
  }
}

Grant it (use revokeRole with the same argument to take it away):

mutation {
  userdata {
    user(userId: "U...") {
      grantRole(userRoleId: "role:wiki-editor") {
        userRoleId
        name
      }
    }
  }
}

To make someone an admin, grant role:admin the same way. Users see their global roles under /account/roles.

A global role can also be offered to organizations: once it is organization-assignable, organization admins can give it to their members, and its scopes apply while that organization is the active one.

mutation {
  manage {
    role(roleId: "role:wiki-editor") {
      setOrganizationAssignable(assignable: true)
    }
  }
}

Enable organizations for a client

Three settings decide what a client sees of organizations; Organizations explains the effect on the sign-in flow and on tokens.

  1. Organization usage: NONE (default), OPTIONAL (users pick an organization or their personal account) or REQUIRED.

    mutation {
      orgs {
        setClientOrganizationUsage(clientId: "C...", usage: OPTIONAL)
      }
    }

    The same field is the "Organizations" select on the client's Settings tab in the dashboard.

  2. Which organizations may see the client. This replaces the full list and returns organization ids:

    mutation {
      orgs {
        setClientOrganizations(clientId: "C...", organizations: ["acme", "O..."])
      }
    }

    Organization admins can also do this themselves on their organization's "Client access" page.

  3. Or, for your own first-party apps, let the client see every organization of every user, with no per-organization access and no organization consent:

    mutation {
      manage {
        client(clientId: "C...") {
          setAllOrganizations(enabled: true)
        }
      }
    }

Approve organization access requests

When a client is not enabled for an organization, members can request access on the sign-in screen. List all pending requests:

query {
  orgs {
    accessRequests {
      clientId
      organizationId
      userId
      createdAt
    }
  }
}

Approve (enables the client for the organization and clears every pending request for that pair):

mutation {
  orgs {
    approveOrganizationAccessRequest(clientId: "C...", organization: "acme")
  }
}

Deny with denyOrganizationAccessRequest(clientId:, organization:, userId:); leave out userId to drop all requests for the pair. Organization admins with the org:access permission handle their own requests on /account/organizations/<slug>/access.

Mark an app verified

Until a client is verified, its consent screen shows the publisher (the owning user or organization) with an "Unverified app" badge. Verifying needs an admin token:

mutation {
  manage {
    client(clientId: "C...") {
      setVerified(verified: true)
    }
  }
}

In the dashboard: enter admin mode, open /account/clients/<client id>, click "Mark as verified". To find unverified clients:

query {
  openid {
    clients {
      clientId
      name
      verified
      ownerUserId
      ownerOrganizationId
    }
  }
}

LDAP visibility and app passwords

The built-in LDAP server only shows a client the members of roles you assign to it (this replaces the list):

mutation {
  ldap {
    setClientRoles(clientId: "C...", userRoleIds: ["role:wiki-editor"]) {
      userRoleId
      name
    }
  }
}

Read the current list with ldap { clientRoles(clientId: "C...") { userRoleId name } }. Internally each role becomes an allowed ldap:search:<role id> scope of the client on the server's own audience.

Users bind to LDAP with app passwords, not their main password. Users can't create them yet; issue one for them (the password is returned once):

mutation {
  ldap {
    issueAppPassword(userId: "U...", clientId: "C...", name: "Mail on laptop")
  }
}

List with appPasswords(userId:), revoke with revokeAppPassword(userId:, clientId:, name:). Users see their app passwords (without the secret) under /account/app-passwords. Server setup and bind DNs are in LDAP.