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.
-
The first admin has to be bootstrapped outside the UI, see Running pykrete.
-
Further admins: grant them
role:admin(see Create a role and grant it to a user). Any role that includesadmin:graphqlon the server's own audience works the same way.
Two ways in
GraphiQL
Open https://auth.infra.delta.rocks/graphiql (a GET /graphql redirects there).
-
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.
-
The GraphiQL client always requires the
admin:graphqlscope. If your account has no role that grants it, the authorization fails and the toolbar shows the error with a "Sign in again" button. -
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.
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:
-
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; -
requests
openid admin:graphqlfor the dashboard's built-in "Account" client, so the new token carries bothaccountandadmin:graphql; -
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.
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>orhttps://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 |
|---|---|---|
|
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. |
|
admin |
Users: list and look up (by id or username), create, grant and revoke global roles, issue registration codes. |
|
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. |
|
admin |
Which roles a client can see over LDAP, app passwords. See LDAP. |
|
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 ( |
|
account |
The signed-in user's own account, see Account dashboard. |
|
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
httporhttps; query and fragment are dropped.public: truemarks the default URL used when a request has noredirect_uri; keep at most one public URL. -
Grant types:
AUTHORIZATION_CODE,REFRESH_TOKEN,DEVICE_CODE.IMPLICITis rejected. The client credentials grant (CLIENT_CREDENTIALS) is not available yet. -
resetClientSecretreturns a newDELTA-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.
/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
}
}
}
}
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.
-
Organization usage:
NONE(default),OPTIONAL(users pick an organization or their personal account) orREQUIRED.mutation { orgs { setClientOrganizationUsage(clientId: "C...", usage: OPTIONAL) } }The same field is the "Organizations" select on the client's Settings tab in the dashboard.
-
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.
-
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.