Skip to content

Access control: claims, roles, permissions

Every route this server maps declares the permission a caller needs — runs.read, config.write, secrets.read. Permissions are bundled into roles. Roles are held by callers because their identity provider says so.

That last sentence is the whole design, and it is worth stating plainly:

The directory says WHO a caller is. Signing in happens there and nowhere else: a caller the directory refuses resolves to nobody before any mapping is consulted, so nothing here can let somebody in. What a caller may DO is this installation's decision — a role claim, a group membership it maps, or a role an administrator grants a person on the Access surface.

A grant decides a role and never decides access. There is no user store, and the list of people on the Access surface is not one: it is the callers this installation has actually seen, kept so an administrator picks a person instead of copying an identifier out of a directory console.

The three built-in roles

Role Holds
reader what the agent DID — runs, the live run drawer, the catalog, the connection snapshot, its own identity
operator everything reader holds, plus run control, run deletion, project init, connection probes and design conversations
admin every permission in the catalog, configuration, secrets, access and archives included

reader deliberately holds no config.read: the configuration is where credentials, trackers and repositories are named, and "may look at the run list" is not "may read the installation".

The permission catalog is closed. These are all of them:

Permission Guards reader operator
runs.read the run list, run detail, pull requests, run statistics yes yes
runs.watch the live sandbox drawer, whose expansion is shared across viewers yes yes
runs.control cancel, answer a parked run, retry a handed-back ticket, judge a criterion yes
runs.delete deleting a run, clearing finished runs yes
projects.init initializing a project without a ticket yes
catalog.read the skills catalog yes yes
diagnostics.read the connection snapshot yes yes
diagnostics.probe running a probe, an authenticated call into your systems yes
identity.read GET /api/identity; every validated caller holds it, roles or not yes yes
dialog.write holding a design conversation, which files tickets yes
config.read, config.write the configuration catalog and settings
config.export, config.import the config export and import
secrets.read, secrets.write secret entities
access.read, access.write the Access surface
archive.export, archive.import the whole-database archive

admin holds every row. The same table decides the live stream: a call on the dashboard's hub is checked against it like an HTTP route, and the token for the hub is taken from the connection handshake.

Turning it on

Authentication is two halves, and they are configured in two places.

The server validates tokens. Its auth: block is bootstrap configuration, read from agentsmith.yml and the environment before the config store exists:

auth:
  authority: https://login.example.com/realms/agentsmith
  audience: agent-smith
  enforce: false
  name_claim: sub          # names a caller on the identity page and in the audit trail

Leave the block out and nothing authenticates. The environment wins over the file, field by field, and an environment variable alone is enough to create the block:

Field Variable Default
authority AGENTSMITH_AUTH_AUTHORITY none
audience AGENTSMITH_AUTH_AUDIENCE none
enforce AGENTSMITH_AUTH_ENFORCE false
role_claim AGENTSMITH_AUTH_ROLE_CLAIM roles
group_claim AGENTSMITH_AUTH_GROUP_CLAIM groups
name_claim AGENTSMITH_AUTH_NAME_CLAIM sub

The dashboard signs people in, as a public OIDC client with the code flow and PKCE. It reads AGENTSMITH_AUTH_AUTHORITY and AGENTSMITH_AUTH_AUDIENCE too, so one value in a compose .env serves both halves, plus AGENTSMITH_AUTH_CLIENT_ID, AGENTSMITH_AUTH_SCOPES and AGENTSMITH_AUTH_REDIRECT_PATH. The reply URL has to be registered as a single-page application, and the scopes need offline_access for a session to outlive its first token. The details are on the dashboard page.

First login, before anything is mapped

A fresh installation has an authority and no mapping, so the first caller resolves to zero roles. That is expected, and it is why the walkthrough starts with a way in.

1. Give yourself a way back in. AGENTSMITH_ADMIN_GRANT names callers who hold admin whatever the directory says. It is read from the environment and reaches no editable surface — it is changed where the deployment is.

AGENTSMITH_ADMIN_GRANT=sub:00000000-0000-0000-0000-000000000000

Every entry is prefixed with the claim it is matched against, group: or sub:, and is matched against only that claim. A grant tried across claim types would turn any claim that happened to contain the value — an email, say — into an administrator. An entry without a prefix grants nothing and is reported as a finding.

2. Ask the server what your token carried. GET /api/identity answers any authenticated caller, including one with no roles at all — that is the caller it exists for.

{
  "authenticated": true,
  "subject": "00000000-0000-0000-0000-000000000000",
  "issuer": "https://login.example.com/realms/agentsmith",
  "roleClaim": "roles",
  "groupClaim": "groups",
  "roleClaimValues": [],
  "groupClaimValues": ["11111111-1111-1111-1111-111111111111"],
  "roles": ["admin"],
  "permissions": ["catalog.read", "config.read", "..."],
  "findings": []
}

roleClaim and groupClaim say which claims were looked in; the two …Values arrays say what was found there. Those values are what a mapping is written from.

If the page instead says "This server did not accept your token", no mapping will help: the token was refused before any claim in it was read. The page names which check refused it — audience, issuer, signature, expiry — and shows both sides: the authority and audience this server expects, beside the audience, issuer and version the token you just presented carried. The fix is written from the difference between them.

Before any token has been refused, the page can still catch one mistake on its own: when the scopes the dashboard asks for (AGENTSMITH_AUTH_SCOPES) name a different resource than the audience this server validates, it says "These scopes name a different resource". Silence there is not a promise; a scope nobody consented to, or an API that mints a different token version, passes that comparison.

One issuer, one audience, matched exactly

The audience is compared as a single string, and the issuer is the one this server's authority publishes in its discovery document. There is no second accepted form of either: no list, no prefix rule, no per-token adjustment.

That is what lets any one OIDC authority work here — a realm that has no api:// form and no token versions is a first-class case rather than an exception — but it also means a directory that mints more than one shape needs the matching one named.

Microsoft Entra mints two, and the API decides which. Not the endpoint the sign-in used: the resource's own app registration carries requestedAccessTokenVersion, and while it is unset (the default of a portal-created registration) that resource gets version 1 access tokens even when every client talks to the /v2.0 endpoint. The two shapes are:

version 1 version 2
aud api://<api-client-id> <api-client-id>
iss https://sts.windows.net/<tenant>/ https://login.microsoftonline.com/<tenant>/v2.0
authority to configure https://login.microsoftonline.com/<tenant>/ https://login.microsoftonline.com/<tenant>/v2.0
name claim to configure upn preferred_username

Either column works, as long as all of it comes from one column. Correcting only the audience leaves the issuer refusing, because the issuer follows the authority rather than being configured beside it.

The dashboard's own authority is a separate setting and may legitimately sit in the other column: it governs the sign-in and the id_token, while the server's governs the access token. The banner that warns about two halves of a sign-in does not fire on that difference alone — it asks whether a token actually survived.

If you have another API in the same directory that accepts both shapes, it is most likely built on Microsoft.Identity.Web, which picks the expected audience per request from the token's ver claim. This server does not, and does not intend to: the value it validates is the value an operator configured.

3. Grant the role in the Config Studio, under Access. Four panes over one document:

  • People — everyone this installation has seen, searchable and paged, each row showing the roles they hold and where each one came from. Grant a role from the row. Somebody who has not called yet is added by hand and reads not signed in yet rather than a timestamp.
  • Groups — every group value that has arrived or been mapped, and the roles it grants.
  • Roles — the three built-in roles, what each holds, and how many people and groups carry it, above the full permission matrix. Below it, the roles this installation composed for itself: pick permissions out of the catalog, and the role stands beside the built-in three. The built-in bundles themselves are fixed.
  • Claim names — which claims roles and groups are read out of, and the claim callers are named by.

4. There is no step four. A saved grant applies to the next request. What a role MEANS and who holds it are both application configuration: they change when a team does, so they live in the config store like every other setting and reload live.

The Access surface needs access.read and access.write, which only admin holds. config.write is deliberately not enough: a custom role may bundle config.write, and granting a role is how such a caller would make themselves an administrator and collect the secrets permissions the catalog keeps separable.

A grant remembers the claim it was written against

A person grant stores {claim, value}, not a bare value, and resolves only while claim is the configured name_claim. Written under preferred_username and later read under email, one value can name a different person entirely — the same cross-claim collision AGENTSMITH_ADMIN_GRANT's mandatory prefix refuses. A grant whose claim is no longer in force grants nothing and says so in findings.

Values compare ordinally: Ada@example.com and ada@example.com are two identifiers, not one word.

Point name_claim at sub if you can

sub is opaque and never reused. email and preferred_username are editable by their holder in common directory configurations — a person who can change their own can claim a grant written for somebody else, and a grant reassigned after somebody leaves goes to whoever inherits the address. The surface warns whenever name_claim is not sub.

Every write leaves a route to admin

A role mapping that reaches the store must leave at least one way to reach admin: a person granted it, a group mapped onto it, a role claim this installation reads, or a non-empty AGENTSMITH_ADMIN_GRANT. The check sits on the document store rather than on a route, because a save, an import, a revert and the bootstrap migration all write a mapping and three of them never pass a settings endpoint. A refusal names the four routes.

Who has been seen, and for how long

A validated caller is NOTED — subject, the name-claim value with its claim, the role and group values that arrived — coalesced in memory and written off the request path. There is no sign-in event to hook: a bearer token is checked on every request and this server holds no session, so a row per validated token would be one write per request per caller. The record fails open: nobody is ever refused because their observation could not be stored.

Observations are kept for observation_retention_days (90 by default) and are not configuration — they never travel in a config export. Removing a person removes their grant and their record in one action.

The authority, the audience and the enforce switch are different — they are bootstrap configuration, read from the file and the environment before the config store exists, because the authority that decides who may read the store cannot be read out of it. A change to those three costs one restart.

An installation upgrading with role_claim, group_claim, group_roles or roles still in its auth: block has them imported once, on the first boot after the upgrade. Nothing is lost, and nothing has to be edited by hand. After that import the store is the single answer, and the file's copy is ignored.

5. Turn enforcement on once GET /api/identity shows the roles you expect. Set an authority with enforce: false first: tokens are validated and nothing is refused, which is how an issuer gets proven before anybody can be locked out.

The server's startup preflight checks this for you. Its sign-in check (category auth) reports, with the authority set, how many callers were accepted inside the observation window and through which route an administrator can be reached. With enforcement on it fails when nobody has been accepted in that window, since every route is then refusing and nothing has proven a token gets through, and when no administrator can be seen from the server, since enforcement without one is a lockout the next restart makes permanent. A failure is listed with the rest of the preflight on /health (see server resilience).

When the authority is unreachable

The server probes its authority at startup and every 30 seconds after. While it cannot reach it, a token the server holds no cached signing keys for can't be validated, and the server says that about itself instead of blaming the token: the 401 carries error="temporarily_unavailable" and points at GET /api/config/findings, where an auth finding names the authority and the cause. The finding is blocking when enforcement is on, which puts it in the dashboard's degraded banner, and advisory when it's off. It clears on its own once the authority answers again.

What resolves, and how it compares

  • Role names fold case. One directory emits an app-role value as the operator capitalised it, another lowercases everything; Admin and admin are the same role.
  • Group values compare exactly. A group value is an opaque identifier, and case-folding an opaque identifier is a smell. The one exception is a leading slash on a group path — the console shows platform-admins, the token carries /platform-admins — which is normalised away.
  • A caller in two mapped groups holds the union of both bundles, and the admin grant unions with whatever the token already carried.
  • Custom roles are additive, and composed here. A role you write stands beside the built-in three and never replaces one: a name that collides with a built-in is refused at the save, as is a permission name the catalog does not contain — compared exactly, so Runs.Read is refused the way an invented name is. The refusal comes at the save, while the person who typed the name is still looking at it, rather than as a permission that silently never resolves.
  • A role already configured is left alone unless you touch it. The validation is over what a save ADDS OR CHANGES: a legacy bundle naming a permission this version no longer has keeps working, keeps its findings entry, and does not refuse an unrelated save.
  • Removing a role is refused while a holder can be seen. Losing a role means losing every permission with it, so the refusal names who still holds it — granted here, through a mapped group, or seen arriving with it in the directory's claim. That list is a floor: somebody who holds it only through the directory and has not signed in inside the observation window is not in it. Withdrawing a permission FROM a role is not refused — narrowing a role is how a role is corrected.
  • A person grant unions with the directory's roles. Somebody can hold reader from their directory and admin from a grant at the same time.

A directory that nests its roles

Some providers do not put role names in a flat claim. Realm roles under realm_access.roles and client roles under resource_access.<client>.roles arrive as a single claim whose value is JSON text, and no flat role_claim name can address either. Such an installation maps groups instead — or has its operator add a protocol mapper that flattens the roles into a claim of their own.

A directory that hides its groups

Past roughly two hundred group memberships some providers omit the group claim entirely and send _claim_names / _claim_sources instead; a token delivered through a URL fragment carries hasgroups. All three are detected, and reported as themselves:

The token carries '_claim_names', which means the directory left its group claim out because the caller is in too many groups. No group mapping can resolve this caller; grant the role through a role claim instead.

That is a different problem from a group nobody mapped, and it is worth not confusing the two: no mapping you write will ever match, because the values never arrived.

Reading a refusal

With enforcement on, a caller missing a permission gets 403 and a body naming exactly what was missing — never an empty body:

{
  "error": "The caller is missing one or more permissions this route requires.",
  "missingPermissions": ["secrets.read"]
}

A route may state several permissions, and they are required together. The four routes that cross the config/secrets boundary — the change feed, revert, export and import — state both, so a holder of config.read alone is refused for exactly one reason, and the body says which.

A 401 with a WWW-Authenticate: Bearer header is a different answer: it means no valid token was presented at all. A live-stream call refused for a missing permission fails with a hub error of the same shape, naming the method and the permission it needs.

Attribution

A config change is attributed to the principal — the claim name_claim names, sub by default. A change made with no principal is attributed to dashboard. Nothing about the attribution comes from the request, so nothing about it can be forged by a client.