Skip to main content

☰ 🪪 Datalayer IAM

KubernetesREST API

Datalayer IAM service provides the Identity and Access management to Datalayer and supports a variety of Authentication and Authorisation methods. It allows to manage the following artifacts.

🛂 Personal and Organisation Accounts.

🧑‍🤝‍🧑 Teams.

🛡️ Authorization Policies.

The OAuth 2.1 authorization server​

IAM is where an MCP client authenticates. It serves the endpoints an agent discovers through RFC 8414 metadata — /api/iam/v1/oauth/authorize, /token, /revoke, /register — and the /.well-known/oauth-authorization-server document that points at them. The MCP Server is the resource those tokens are issued for, and refuses one issued for anything else.

How a client is registered​

By Client ID Metadata Document: the client_id is a URL that IAM fetches, validates and caches, and the document at it declares the client's name, its redirect URIs and what it may ask for. Advertised as client_id_metadata_document_supported in the metadata, so a client discovers it rather than being told.

Dynamic Client Registration (RFC 7591, /oauth/register) still works and is the deprecated fallback. A document needs no registration call and no stored secret, which is what makes it right for a client that discovers servers at runtime.

The fetch is bounded, and the bounds are the security properties rather than tuning:

Timeout3 s for the whole fetch — connect, send, read
Redirects3, all on the same host
JWKS64 KiB
Logo256 KiB
Cache5 min to 24 h; 1 h where the document says nothing

A client's document lives on the client's own server, so fetching it is IAM reaching out to a third party during an authorization. The timeout and the size caps are what stop that server from holding an authorization open or handing back a download. Redirects stay on the host because the hostname is the client's identity: following one off-host would let a document delegate its identity to somewhere the person approving it never saw.

no-store gets the lower bound, not no caching

A document refetched on every request would let the client's server watch every authorization its client is part of — who signs in, when, how often. The cache exists to prevent that, so a document asking not to be cached is still held for five minutes.

A URL client authenticates at the token endpoint with none or private_key_jwt, never a shared secret: a document anybody can read cannot hold one. The symmetric methods remain for the deprecated registration path.

Redirect URIs, and the one exception​

A redirect URI is matched exactly against the client's document — the whole string as the client wrote it, never a prefix, never a different scheme, path, query or fragment.

The exception is the port of a loopback address, where any port is accepted.

This exception is not optional

RFC 8252 §7.3. A native application — Claude Code, Claude Desktop, any desktop agent — listens on whatever port the operating system hands it at the moment it starts the flow: http://localhost:3118/callback today, something else tomorrow. No document can list those ports, because the client does not know them when it is written.

Matching the port too refuses the client on its own callback:

{"error":"invalid_request",
"error_description":"Redirect URI [http://localhost:3118/callback] is not registered for this client."}

which reads to whoever hit it as the server being broken. If you see this error, the host and the path are what to check — those are still compared exactly, and 127.0.0.1 is a different address from localhost as far as matching is concerned.

Nothing else is relaxed. A public redirect gets no exception at all: there the port is part of the address the client promised to be at.

PKCE​

Required, S256 only. An authorization request without code_challenge, or with plain, is refused before any browser is redirected — so the consent screen only ever shows a request that would succeed.

Scopes​

What the resource parameter names wins over what the client asked for. The resource is chosen by the person setting the client up, and it is the only say they have over a client that sends whatever scopes it likes.

The OpenID Connect scopes — openid, profile, email, offline_access — pass through so one sign-in answers both questions. None of them grants anything on the MCP server.

The resource scopes are notebooks:read, notebooks:write, code:execute, data:read, data:write, tools:use and sandboxes:manage. A request that names none of them is offered all of them at consent, unticked, in that order; a client that named one is offered nothing, because it knew what it needed and the server does not second-guess it.

A scope is offered the day a tool requires it, and not before: a consent screen listing something that grants nothing teaches a person that the screen is noise, and the next screen they click past is one that mattered. data:write and tools:use were withheld on that rule until 2026-09-04, when the gateway's toolset and Contents tools began requiring them — until they were offered, enable_toolset and attach_content were tools nobody arriving through this screen could be given. Two tests hold the gateway's required scopes and this list against each other in both directions, which is what noticed.

Grant types​

grant_types_supported names five, and the token endpoint accepts exactly those five — one list in services/oauth_provider.py, read by the OAuth metadata, the OpenID document and the unsupported_grant_type refusal alike.

GrantWho uses it
authorization_codeEvery MCP client, with PKCE
refresh_tokenThe same clients, to stay connected
client_credentialsA service agent, with its own uid and key: sub and agent_uid are the agent, and there is no person behind it
urn:ietf:params:oauth:grant-type:jwt-bearerA registered client presenting a signed assertion as the grant itself (RFC 7523), verified against the keys its client document publishes
urn:ietf:params:oauth:grant-type:token-exchangeDatalayer services only
Token exchange is advertised, and refused to everyone but services

RFC 8693. The audience of an access token is what stops one minted for the MCP gateway being replayed against another API — and the gateway genuinely does need to call the Spacer on the user's behalf, holding a token that names the gateway. It exchanges that token for one naming the Spacer.

It is in the metadata although only a trusted service may use it: the document describes what the server supports, and who may use it is what the refusal says — invalid_client, precisely. A capability left out of the description because it is restricted is a capability nobody can discover is there, and this one was omitted for exactly that reason until a test held the two lists against each other.

Two ways for a client to be a principal with no person behind it

client_credentials takes a service agent's uid and key and answers a token that is the agent's own: sub and agent_uid are the agent, and the scopes are its registration narrowed to what it asked for. A wrong key is invalid_client whichever way it is wrong — telling "unknown" from "revoked" from "somebody else's" would be an oracle over other organizations' agents.

jwt-bearer (RFC 7523) takes a registered client's signed assertion as the grant itself; the client document's published keys verify it, and what is minted is a token for the client as a principal. See Service agents.

Organization, team and personal MCP policy​

/api/iam/v1/mcp-policies/{scope}/{subject_uid} serves and stores the layers the MCP Server enforces, at three scopes: organization, team and personal. An organization's owner writes its layer, a team's owner writes the team's within it, and a person writes their own.

Six rules, and no others:

RuleValue
toolDenylistTool names refused
toolAllowlistTool names permitted, everything else refused
allowedClientsClient ids — a CIMD URL admits that client and no other
maxCallsPerMinutePer-subject rate cap
maxCreditsPerDayThe daily budget
maxConcurrentSandboxesSandboxes running at once
gpuHoursPerMonthGPU-hours over a trailing 30 days. Refuses a launch onto a GPU environment only, and counts every GPU-hour billed to the scope rather than agents' alone
storageBytesBytes stored, counted over every live object version rather than every object. Refuses every launch when reached, since every sandbox can write
A rule IAM does not know is refused, not stored

The catalogue is kept beside the gateway's own ENFORCED_RULES. A rule the gateway enforces but IAM does not list cannot be saved — harmless, and visible immediately. The other way round is a policy page that lies: a rule stored, displayed, and enforced by nothing.

So a mistyped toolDenyList is an error the administrator sees, rather than a setting that quietly does nothing.

The last three are the quotas. They are policy rules rather than a surface of their own, because a second place to write a limit would be a second answer to what an organization is allowed. The gateway reads them and refuses against them; GET /api/mcp/v1/organizations/{uid}/usage puts each beside its use.

A team layer cannot widen its organization's

The gateway intersects a team's layer with the organization's, so a team owner who saves maxCreditsPerDay: 500 under an organization capped at 100 gets 100 in practice. Until IAM refused it, the write was accepted and shown on the page as 500 — a policy page that lied. IAM now refuses a team layer wider than its organization's at the write (400, WidensPolicy): every cap above the organization's ceiling, and a toolAllowlist that reaches into the organization's toolDenylist. An organization layer IAM cannot read does not block the write.

Alert rules​

McpAlertRule, stored per organization. A rule names a condition, an operator, a threshold, a window and a destination. IAM refuses a rule it cannot evaluate — an unknown condition, a window of zero, a threshold of nan — rather than storing it: a refused rule is visible, while a stored one nobody can evaluate is a condition that looks watched and is not.

Service agents​

/api/iam/v1/mcp-service-agents/…, owned by an organization: created, rotated and revoked by its owners. A pipeline, a CI job or a bot holds its own key and spends the organization's credits under its own agent_uid — so the work does not stop when the engineer who set it up leaves, and its spend is attributed to it rather than to them.

They do not use client_credentials. The gateway presents the key to POST …/authenticate, which answers with the principal, and the endpoint is restricted to trusted services: anything that may call it may test keys.

A bad key is a 401, never a 200 with an empty principal

A caller that reads a truthful empty answer as an anonymous success is how an unauthenticated agent gets a session.

org_uid and team_uid claims​

Chosen at consent and stamped into the token. The gateway keys policy, quotas, alerts and the audit on org_uid, so a token without one is a personal-scope session. IAM validates the choice against membership before stamping it: a person cannot act for an organization they do not belong to, and a team is refused if it is not that organization's.

The administrator routes require the role​

PUT /users/{id}/roles/{role}, DELETE /users/{id}, POST /api-keys/temporary, PUT /credits/users/{user_id} and the platform usage reads require platform_admin (two of them accept platform_growth_manager as well), and that is enforced by a dependency FastAPI resolves on each route.

It was not, until 2026-09-04. The routes were marked with a @require_auth(PLATFORM_ADMIN_AUTH) decorator that sets two attributes on the function and calls straight through — its own docstring says it is "mainly for compatibility and marking functions as requiring auth". The enforcement was meant to happen in AuthnMiddleware, which reads those attributes; a POST /api/iam/v1/api-keys/temporary sent to prod1 with nothing but an ordinary bearer token reached FastAPI's own body validation and answered 422, which is what a request that was never role-checked looks like. The middleware also calls an authz() of a different signature entirely, so where it does reach the check it raises, is caught, and would refuse everyone; AuthzMiddleware is commented out. DATALAYER_AUTHZ_ENGINE is none, which grants every tuple it is asked about.

So those routes were guarded by authentication alone — any account that could log in could grant itself platform_admin, delete a user, or mint a ten-minute key carrying another person's roles.

One route had lost its guard entirely: DELETE /outbounds/users/{user_id} had both its decorator and its user parameter commented out in an unexplained squash in October 2025, leaving anyone able to unsubscribe any user from outbound notifications by uid — including the alerts an organization relies on — while its own docstring still said platform_admin and the POST beside it required one. Nothing in the platform links to it; the mailer's campaigns ask people to reply with "unsubscribe". It requires the role again.

That docstring was the only thing still telling the truth, so it is now read by a test: an endpoint whose docstring names a privileged role and nothing else must actually require it. Where a docstring names an owner as well — "this team's team_owner, its organization_owner, or platform_admin" — the decision is about a particular organization or team, no dependency could make it, and the enforcement is in the body against the object being managed. Six organization and team docstrings said platform_admin when the code correctly admitted owners too; they say what the code does now.

The decorator is left in place as documentation of intent, and is not the guard. require_auth(PLATFORM_MEMBER_AUTH) — 120 of the 144 uses — is deliberately still not enforced: it means "authenticated", and a service authenticating with an API key has no roles at all, so requiring membership there would refuse every service-to-service call in the platform. Giving service identities a role is what that would need first.

platform_features_previewer stages a rollout, and nothing is staged​

The role is platform_features_previewer, granted with PUT /users/{id}/roles/{role} like any other, and the web application reads it from the account it already has — IAM has nothing to enforce. It staged the benchmark navigation (PLAN_BENCHMARK.md B1-14). The benchmark, MCP and orchestration pages have since shipped to every account, so no page of the web application is behind the role today, and a report that a page is missing is not a question about it.

When a feature is staged through it again, three things are worth knowing:

  • A platform_admin sees previews too. An administrator who cannot see a preview cannot answer a question about it or reproduce what a previewer reported.
  • The roles are read in four spellings — roles, roles_ss, effective_roles and effective_roles_ss — because a record that came from Solr or a role resolved through a group arrives under one of the last three. A gate reading only the first refuses an administrator and looks like a permissions bug.
  • Nothing is hidden, only staged. The routes stay routed and an account without the role is redirected to the home page, so a link somebody saved still resolves. This is not an access control: a preview is unfinished, not confidential, and nothing behind it is data another account may not see.

A disconnected agent stops within seconds​

Revoking a grant stops the refresh at once. The access token already in the agent's hands was another matter: the gateway verifies the signature, the issuer, the expiry and the audience, and asked nobody whether the consent behind the token still stood, so an agent went on working for up to an hour after it was disconnected.

IAM stamps grant_uid on the tokens it mints from a grant, and GET /oauth/grants/{uid}/live answers whether that grant still stands — closed to everyone but a platform service, like the task-grant routes, since an agent that could ask it could enumerate which grant uids exist. Unknown is answered as not live, so a uid that is gone and one that was invented both stop a call.

The gateway asks before it judges a call and remembers the answer for ten seconds, so the honest claim is within seconds, not "on the very next request" — the alternative buys a fraction of a second and spends a round trip on every call in the platform. It refuses as grant_revoked.

It did not work until 2026-09-05, and nothing said so. The route called get_oauth_grant, which the Solr store exports and this service had never imported, so it raised NameError and answered 500 to every question — and the gateway, which allows a call on its token's own validity when IAM cannot say, allowed every one. Every disconnection was silently an hour again, from the day the question was added. The tests around it faked the per-user listing and never reached the accessor. Measured on prod1 after the fix: IAM stops listing the grant at once, and the gateway refuses the agent 11.3 s after the disconnection — the ten-second cache, the commit, and one poll. Two guards hold it now: is_grant_live is tested through the store's own accessor, and tests/test_session_controls.py runs ruff's undefined-name check (F821) over the whole package, so a name used but never imported fails a test instead of the first request that needed it. That guard found POST /mfa reading two names it was never given — 500 on every call, and had it run, reporting every code as valid — on the day it was added.

allowedClients is enforced by the gateway on every call, and was asked by nobody at consent — so a person could connect a client their organization does not admit, watch it succeed, and then have every call refused with a reason its owner could not act on. IAM asks the same question at the moment the organization is settled, which is when the person presses Approve, and answers access_denied naming what to do: ask an owner to add the client, or connect without acting for the organization.

The matcher lives in datalayer_common and both services import it. A copy in each would drift, and a client admitted at consent and refused at every call reads as a broken agent rather than as two lists disagreeing. A list may name the exact client document or the host that publishes it; an empty list is not an allowlist; and nothing is refused for a personal-scope authorization, an organization with no list, or a policy that could not be read.

Session controls​

Three things an organization can do about how long its agents stay connected, all of them here rather than at the gateway: a session's age is a fact about the grant, and the gateway never sees one.

sessionMaxHours is an organization's MCP policy rule, enforced at the token endpoint. A refresh past the limit is refused invalid_grant — "sign in again" — and the grant is revoked with it: left alive it would be offered at every retry, and the client would show a connection that fails rather than one that has ended. An access token already minted lives out its own hour, which is what "the session ends at the next refresh" means. Nothing is enforced for a personal-scope grant, for an organization that sets no limit, or for a policy that could not be read — signing everybody out because Solr hiccuped is a worse failure than the one this guards against.

It is the first rule IAM enforces itself. ENFORCED_RULES is the list the gateway enforces and is held equal to the gateway's own copy by a test; IAM_ENFORCED_RULES is this one, and STORABLE_RULES is the union — a rule in neither cannot be saved. Until 2026-09-05 sessionMaxHours was in neither while the Policies page offered Session at most, so setting it failed and leaving it unset looked the same as a limit that was working.

Forced re-consent has no flag. The grant is the consent, so ending it sends the next authorization through the consent screen. A second flag would be a second answer to the same question, and the two would drift.

Revoke-all, in two shapes, neither of which touches anything it was not asked to:

RouteWho mayWhat it ends
DELETE /oauth/connected-agentsthe person themselvesevery connection they hold, anywhere — sign out everywhere
DELETE /oauth/teams/{uid}/connected-agentswhoever may manage that team (_can_manage_team)every connection consented in that team, whoever holds it

Ending a team's sessions is the same kind of authority as removing somebody from it, so it asks the same question. Neither route reaches a personal agent or another team's — the narrowness the deprovisioning rule below has, one principal wider.

Deprovisioning revokes the agents that came with the membership​

Removing somebody from an organization or a team revokes the OAuth grants they hold in that organization or team, at the moment of removal. A grant carries the org_uid it was consented in and a refresh mints the same scope, so an agent left connected would go on acting for the organization after the membership is gone — reading its policy, spending its budget and writing into its audit.

Narrow on purpose. A deprovisioned member keeps their own agents and their other employers': revoking every grant of theirs would take agents the organization never granted and has no standing to revoke. A person removed from an organization is removed from its teams first, and each team revokes its own, because a team grant is not an organization grant.

Each revocation is an oauth.token.revoke audit row whose via is organization_membership_removed or team_membership_removed, and whose user_uid is the person it was for.

What is not here yet​

Named plainly, because the MCP plan refers to it and an operator should know:

  • DPoP. Not implemented, and deliberately not built ahead of the Agent Identity Working Group's finalized MCP profile. Sender-constrained tokens are the right answer to a stolen bearer token, and building against a draft that then changes would leave a deployed proof format nobody else speaks.

An organization's identity provider​

Enterprise sign-in used to be a single Okta for the whole deployment, read from DATALAYER_OKTA_DOMAIN, DATALAYER_OKTA_CLIENT_ID and DATALAYER_OKTA_CLIENT_SECRET. Every organization shared it, and one that already had a directory could not use it. An organization now registers its own, and a sign-in is routed to it by an email domain the organization claims.

RouteWhoWhat it does
GET /api/iam/v1/organizations/{uid}/identity-providersa memberThe organization's providers, disabled ones included
POST …/identity-providersan ownerRegister one
GET …/identity-providers/{uid}a memberOne of them
PUT …/identity-providers/{uid}an ownerChange it, keeping what the request does not name
POST …/identity-providers/{uid}/disable and /enablean ownerStop and restart sign-in through it
DELETE …/identity-providers/{uid}an ownerRemove it outright

A registration is {"name", "issuer", "client_id", "domains", "group_claim", "client_secret_ref", "enabled"}. Owners register and change, because a provider decides who gets into the organization at all; members may read, because it is what they are sent to. A provider belonging to another organization answers 404 rather than 403 — saying it exists but is not yours says that it exists.

A claim is not proof. Whoever holds a domain's claim receives everybody who signs in with an address at it, so a registry that took an organization's word for gmail.com would hand it every sign-in for that domain. A claimed domain therefore routes nothing until it is verified:

RouteWhoWhat it does
GET …/identity-providers/{uid}/domains/{domain}/verificationa memberThe TXT record to publish
POST …/identity-providers/{uid}/domains/{domain}/verifyan ownerRead it back and mark the domain proved

Publish a TXT record at _datalayer-verification.<domain> whose value is datalayer-domain-verification=<token>, then verify. The token is minted per provider, so a record published for one organization cannot prove another's claim to the same domain, and it is not a secret: it proves nothing without control of the domain's DNS, which is the whole point. The subdomain keeps the record away from SPF and DMARC at the apex.

Uniqueness is between verified claims. An unverified claim blocks nobody — otherwise squatting one would be enough to stop a competitor signing in — and exclusivity is checked at verification as well as at registration, because a claim can sit unverified while somebody else proves the same domain. A disabled provider keeps its verification, so re-enabling one cannot quietly take a domain proved in the meantime, and the lookup answers nothing at all on a double proof rather than choosing between them.

The issuer must be https://. The discovery document, the signing keys and the sign-in redirect all travel over it.

The client secret is never stored. IAM is the client to the organization's provider, so unlike an inbound client secret it cannot be kept as a hash — it has to be sent. client_secret_ref names a secret in the organization's own IAM secret store, and IAM resolves it by the organization's uid when a sign-in needs it, there being no caller at that moment to read it as. A reference that looks like a URL, or a secret pasted into the field, is refused: this record is rendered in a console.

There is deliberately no route that resolves an email address to a provider. The sign-in flow needs that lookup and calls the service directly; an endpoint for it would answer, one domain at a time, which companies are Datalayer customers.

Signing in through it​

A registered provider's endpoints are discovered, not configured: they are read from the OpenID configuration at its issuer and cached for an hour, so a provider's own availability is not on the sign-in path. Two checks are made on that document before anything is sent to what it names — it must claim the issuer it was fetched from (otherwise a provider registered as acme.okta.com could hand back somebody else's endpoints and take that organization's people, and their authorization codes, with them), and every endpoint must be https.

The sign-in is started with PKCE S256 and a nonce. PKCE is always used: a provider may be registered with no client secret at all, and for one of those PKCE is the only thing standing between an intercepted code and a session. A provider that offers only plain is refused rather than downgraded. The state, nonce and PKCE verifier are handed back to the caller to keep with that browser's session rather than stored here, because this service has several replicas.

On the way back, the code is exchanged at the discovered token endpoint — with the client secret for a confidential client, on PKCE alone for a public one — and the ID token is verified before any claim in it is believed:

CheckedWhy
Signature, against the key at jwks_uri under the token's own kidEverything else is only as good as this
Algorithm, against an asymmetric allow-listnone is unsigned; HS256 verifies with a shared secret, so a provider's client could mint a token for anybody
issA token from another provider is not accepted by this registration
aud, and azp when several audiences are namedA token issued for another client of the same provider is not ours
nonceMakes a token captured from one sign-in useless in another
exp, with a minute of skewNo more generous than that

The person is identified by (issuer, sub), never sub alone: a sub is unique within one provider and says nothing across two, so two directories could each have a 00u123 and linking on it would join two different people into one account. email_verified is carried through rather than acted on — whether an unverified address may be linked to an account is a provisioning rule, decided where the account is.

The account a verified identity signs in as​

A verified token is not yet a session. Which Datalayer account it is, whether one is made, and what the person gets in the organization are policy decisions rather than protocol ones, so each is set out here.

The address must be at a domain the provider has proved — checked a second time, and not redundantly. Routing checks the domain somebody typed; this checks the one the provider came back with, and they need not be the same. Without it, a registered provider could assert an address at any domain at all and verification would only ever have decided which button was shown.

An account that already exists is not taken. If the asserted address matches a Datalayer account that nobody has linked to this provider — somebody who signed up with their work address through GitHub, most often — the sign-in is refused and says what to do about it. Holding a domain means holding its mailboxes, so the organization could reach the address; that is not the same as being handed a live session for an account carrying personal work, credits and membership of other organizations, whose owner was never asked. The person connects the two themselves from a session they already have (GET /oauth2/enterprise/authz/url/link).

An organization that wants the smoother rollout sets allow_email_linking on its provider. Then the adoption happens — but only on a proved domain and an email_verified claim, and never when two accounts share the address. The default is the cautious one because the costs are not symmetric: refusing locks somebody out of a sign-in route they were not using yet, adopting hands over an account.

Joining the organization is automatic; being anything more is mapped, not assumed. Signing in through a directory an organization registered, on a domain it proved, is what membership means, so the person is added as an organization_member every time. Anything beyond that is granted only when the provider's own role_mappings say a group in the token earns it.

Group → role mapping​

A provider names, in role_mappings, which of the token's groups grant which role: organization_owner, organization_security_auditor, organization_user_reviewer, organization_member, or a team's team_owner / team_member alongside the team_uid naming which team. Checked when the mapping is written, not trusted at sign-in — an unknown role is refused before it is stored, and a team role's team_uid has to name one of this organization's own teams, checked the same way domain verification checks a domain: a provider reaching into another organization's team is the same kind of boundary crossing.

Reaching organization_owner through a mapping is not a privilege escalation to guard against. A mapping only ever grants within the organization that owns the registered provider — the directory is the organization's own, proved by the domain it verified, and the authority it hands out reaches no further than the organization that wired it up.

It only ever adds. A sign-in grants a mapped role the person does not already hold, and never revokes one a previous sign-in granted: revoking on a token's absence of a group would read a slow directory sync, an outage, or a token that simply omitted groups this one time as "no longer entitled," and demote somebody worse than doing nothing. Taking a role away is deprovisioning's job — SCIM's, when it lands, since a sign-in is too weak a signal to act as one.

The console​

An organization's owners manage its directory from the Identity page of the enterprise console — register, edit, enable, disable and remove a provider, edit its group → role mapping, and prove a domain from its own dialog rather than as a field on the edit form, since publishing DNS and saving the provider's configuration are different acts and conflating them would make "Save" look like it also verifies. A claimed domain and a verified one are drawn apart there too — a grey label beside one that routes nothing, a green one beside one that does — so an owner cannot mistake a claim for something already live.

Reading the page needs no more than organization membership with the security-auditor role, the same as policy, teams and alerts: which directory an organization trusts and what its mapping grants is exactly the posture an audit is for, and the page renders no secret — the client secret is a reference kept in the organization's own store, never the value.

RouteWhoWhat
GET /oauth2/enterprise/authz/urlanyoneWhere an address should sign in. enterprise: false is the ordinary answer, not a failure — the caller then shows the sign-in it always did
GET /oauth2/enterprise/authz/url/linka memberThe same, from a session, to attach the directory to that account
GET /oauth2/enterprise/callbackthe provider's redirectCompletes the sign-in and hands the browser back to the application

The first route is unauthenticated because somebody signing in has no session yet, and so it does say, one address at a time, whether a domain belongs to a customer. That is inherent to routing by domain and is what every product with enterprise sign-in answers when you type a work address.

What the sign-in keeps while the browser is away lives in Solr rather than in the state parameter. The other sign-ins here put a nonce in the URL, which is fine for a nonce; the PKCE verifier is not, because a verifier carried through the browser and the provider is one an interceptor has too. So state is an opaque handle — single-use, spent as it is read, and expiring after ten minutes — and the verifier, the nonce, the redirect and which provider this was sit behind it. A callback URL replayed from browser history, a proxy log or a referrer finds nothing, and a forged handle is refused with the same words as a spent one.

The provider is re-read at the callback rather than carried: one disabled, re-pointed or deleted while somebody was away has had its rules changed, and the record at the end is the one that applies.

What is not here yet

The ID-JAG grant, enterprise-managed authorization, workload identity federation and SCIM build on this registry and are not here.

Anonymous trials​

A visitor who has never signed in can be allowed to run one public benchmark before deciding whether to sign up (BENCHMARK.md, B2-14). What they get is a trial: a principal of its own — type_s: platform_trial in the iam collection — with one benchmark it may run, a credit cap, an expiry, and a short-lived key carrying platform_guest. The work they make is owned by that principal, so when they sign up the same records are given to them rather than recreated.

POST /api/iam/v1/trials is the only unauthenticated route in this service that mints a credential. Three things bound it:

BoundHow
It is off unless you turn it onNo DATALAYER_TRIAL_EVALSET_UID, no trials. GET /trials/offer answers offered: false and POST /trials answers 404. Deploying the code exposes nothing
It is cappedDATALAYER_TRIAL_CREDITS (default 25) is all a trial may spend, checked against the trial's own launches each time it asks to run something
It expiresDATALAYER_TRIAL_TTL_SECONDS (default 1800) is how long the trial and its key live

Issuance is rate limited to five per calling address per ten minutes, by the forwarded address where there is one. That address is a header a determined caller can set, which is why it limits the rate and authorizes nothing.

SettingMeaning
DATALAYER_TRIAL_EVALSET_UIDThe uid of the public benchmark a trial may run. Empty — the default — means this deployment offers no trials
DATALAYER_TRIAL_CREDITSCredits one trial may spend; default 25. A value that is not a positive number is a misconfiguration and the default is used rather than an uncapped trial
DATALAYER_TRIAL_TTL_SECONDSHow long a trial and its key live; default 1800

Turned on for prod1 2026-09-12, at the defaults, against the B0-13 fixture (the same public benchmark the sample and section 16's live report read): GET /trials/offer there answers offered: true.

Claiming. POST /trials/{uid}/claim takes two credentials: the person's, which authenticates the request, and the trial's own key in X-Datalayer-Trial-Token, because a trial uid appears in a URL and knowing one must not be enough to take somebody's work. IAM records the claim once — a second person is refused — and the AI Agents service then moves what it owns, which is every benchmark record the trial made. Both halves are idempotent, so a lost answer is harmless.

Task grants narrowed to resources​

A task grant may name exactly the resources its tokens reach (ORCHESTRATOR.md, O1-06), as OAuth Rich Authorization Requests details (RFC 9396) in authorization_details on POST /api/iam/v1/oauth/task-grants:

[{"type": "datalayer_context", "kind": "notebook", "uid": "01J…", "actions": ["read"]}]

The kind is one of the context reference kinds (notebook, document, cell, dataset, artifact, sandbox, snapshot, file, execution), and the actions read or write, which includes read. A detail that does not say precisely what it allows is refused, never trimmed. The grant keeps each resource once (authorization_details_s in the mcp-gateway collection, mapping version 17).

The token a grant is exchanged for carries task_grant_uid and, when the grant names resources, authorization_details — an empty list for a grant naming none, which reaches nothing, and absent for a grant naming nothing, which is not narrowed. Every service reads both through datalayer_common.auth: a request is allowed only when the person may and the token names the resource, and a narrowed token whose grant was revoked, expired or is gone is refused 401. A service asks GET /api/iam/v1/oauth/task-grants/live with the token itself, which answers about the bearer's own grant; answers are cached ten seconds, and an IAM that does not answer refuses nobody, since the token still reaches only what it names. IAM answers its own grants directly.

Deploy order: Solr's mapping, then IAM, then the services that read tokens, then durable, which issues narrowed grants.

Every execution holds its own grant, a child's included, so a child's token names the child's references and nothing its parent was given besides (ORCHESTRATOR.md, O2-02). IAM needs no model of the tree for that: the control plane refuses a child whose manifest is not a subset of its parent's, and IAM narrows each grant to the manifest it was issued for, so neither refusal can be satisfied by the other (tests/test_delegation_tokens.py).

The execution layer, and explicit scopes​

A grant naming resources is also narrowed in its scopes to what those resources need through the MCP gateway (ORCHESTRATOR.md, O1-17), the way a grant is narrowed to its agent's: a notebook to read gives notebooks:read, a notebook to write adds notebooks:write and code:execute, and any other kind gives nothing yet. Durable asks for exactly those, so an execution's token never carries the empty scope the gateway reads as a person's whole authority.

The gateway evaluates an execution's token against a fourth policy layer, computed from the grant and never stored: GET /api/iam/v1/mcp-policies/execution/{task_grant_uid}, for platform services only.

FieldValue
liveWhether the grant is active and unexpired. An unknown, revoked or expired grant answers {"live": false} and nothing else — never 404, which the gateway reads as a layer narrowing nothing
toolAllowlistThe tools the grant's resources reach, when it names resources. Empty means none
authorizedResourcesThe grant's authorization_details, which the gateway checks a call's notebook against

The table behind toolAllowlist and the scopes is EXECUTION_TOOLS and EXECUTION_SCOPES in datalayer_common.authorization_details. It leaves out the catalogue tools (list_notebooks, find_notebook, list_spaces), whose listing would name the person's other notebooks.

Execution tree reservations​

The credits a tree of orchestration executions may spend are held by IAM, in one reservation for the whole tree (ORCHESTRATOR.md, O1-07), rather than added up by the control plane.

  • The tree's reservation is a reservation like any other, of resource_type execution-tree, keyed on the root execution's id, with a burning_rate of 0: it holds its credits out of the account's balance — and is refused like any reservation when the balance does not have them — and burns nothing itself. DELETE /api/iam/v1/usage/reservations/{id} closes it, charging nothing.
  • A runtime of the tree names it: parent_reservation_uid on POST /api/iam/v1/usage/reservations, which the Operator sends from the runtime request's own parent_reservation_uid. The tree already holds the credits, so the balance is not asked a second time and the runtime holds none of its own while the tree is open. It is granted no more than the tree has left — its limit, less what its ended runtimes were charged, less what its open ones hold at their limit — which is how the runtime stops when the tree's credits are spent. A postpaid account is bounded by its tree all the same: the limit is the one somebody set on the work.
  • Refusals have the shape of insufficient_credits (200, success: false), with a reason of their own: execution_tree_credits_exhausted with the tree's credits_limit, consumed_credits, held_credits and remaining_credits, or execution_tree_not_open when the parent is not an open tree of the billing account. Nothing is written for either.
  • Reading a tree is GET /api/iam/v1/usage/reservations/{id}/tree, an internal route like creating and deleting a reservation (X-Forwarded-User, with billing_entity_uid for an organization or team): whether it is open and what it consumed, holds and has left. A tree billed to another account is 404.
  • External checkouts. Where credits are checked out externally (the Anaconda addon), a tree is refused and the checkout it just made is ended: the checkout would hold the credits and IAM could not draw the tree's runtimes against it. A runtime of a tree never checks anything out.

The record is parent_reservation_uid_s on the usage document. Deploy IAM before the Operator: an IAM that does not know the field would grant a runtime of a tree its full request.

Where IAM's own audit rows go​

IAM records its security decisions — a token issued, refused, exchanged or revoked; a client document fetched or thrown out of the cache; an MCP policy set or removed — in the same McpAuditEvent shape the gateway uses, so the two halves of the story can be read together.

By default they go to the datalayer_iam.audit logger, one JSON line each, for a log pipeline to forward. That is the right default: it works with no Solr, in a container that only has stdout.

It also means those rows are not queryable. "Who changed this policy, and when" has nowhere to be asked, and an auditor ends up reading half the story from the console and half from a log aggregator — which in practice means reading half.

To put them in mcp-audit beside the gateway's rows, so GET /api/mcp/v1/audit answers both:

env:
- name: DATALAYER_IAM_AUDIT_SINK_CLASS
value: datalayer_iam.services.audit_solr_sink:SolrAuditSink
Written to the log as wellAlways — it is what a failed collection write is recovered from
A failed collection writeLogged, not raised
A retried writeAnswers the first row; the row's uid is its idempotency key
TimestampWhen IAM recorded the decision, not when Solr accepted it
The swallow is deliberate

These rows are written on the path of every token IAM issues. Raising on a failed audit write would turn a Solr blip into a refused sign-in.

A row missing from the collection is recoverable from the log. A platform nobody can sign in to is not. If you alert on anything here, alert on the could not be written to mcp-audit warning rather than on a gap in the collection.

Deploy Datalayer IAM​

plane up datalayer-iam
plane ls

Check the availability of the Datalayer IAM Pods.

kubectl get pods -n datalayer-api -l app=iam

Check the logs of the Datalayer IAM Pods.

kubectl logs -n datalayer-api -l app=iam -f

Check the availability of the Datalayer IAM Certificate.

kubectl describe certificate ${DATALAYER_RUN_HOST}-datalayer-api-cert-secret -n datalayer-api

Check the availability of the Datalayer IAM Endpoints.

open https://${DATALAYER_RUN_HOST}/api/iam/version
open https://${DATALAYER_RUN_HOST}/api/iam/v1/ping

Tear Down Datalayer IAM​

If needed, tear down.

plane down datalayer-iam

OpenAPI Specification​

The OpenAPI (Swagger) specification is available online.

IAM Cases​

The following diagrams describe the authentication (Authn) and authorization (Authz) in various cases.

All interactions between JupyterLab and the Datalayer services are over TLS/SSL (HTTP or WebSocket) via the Ingress.

Authenticate from JupyterLab with Username and Password​

Authenticate from JupyterLab with a 3rd Party Token​

Access a Datalayer Runtime from JupyterLab​

Create a Runtimes Runtime from JupyterLab​

Access a Runtimes Runtime from JupyterLab​

IAM at Ingress Level​

In order to use Datalayer IAM as a Nginx Ingress middleware checking the user identity and authorization, the Ingress specification must have the following annotation (any service can be protected, aka forcing authentication and passing policies, using the following annotation on the Ingress).

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/auth-url: "http://datalayer-iam-svc.datalayer-api.svc.cluster.local:9700/api/iam/v1/auth"
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Uri $scheme://$host$request_uri;

LinkedIn publishing connection​

LinkedIn sign-in and publishing are separate grants. Sign-in requests only openid profile email; Connect LinkedIn additionally requests w_member_social. The callback stores the provider response in Vault at datalayer/iam/social/linkedin/<user_uid>/<account_ref>, where the account reference is a stable hash. It never returns the LinkedIn token to the browser.

IAM exposes only authenticated, caller-owned operations under /api/iam/v1/social/linkedin: profile, image upload, post creation and post deletion. The image flow uses the LinkedIn Images API and posting uses the versioned Posts API. DATALAYER_LINKEDIN_API_VERSION pins the API version (default 202608) and must be reviewed before LinkedIn retires that version. The operation reads the caller's connection from Vault; request bodies contain no provider token and no arbitrary upstream URL.

The Library owns publish idempotency and the durable pending, published, failed, deleting and deleted states. An ambiguous provider timeout stays pending for reconciliation and is not replayed. A platform administrator may resolve only a provider_timeout_ambiguous share through Library's POST /api/library/v1/admin/shares/linkedin/reconcile endpoint. The request names the share's user and idempotency key, a bounded evidence note, and either outcome: published with the provider's urn:li:share:* or urn:li:ugcPost:*, or outcome: absent. The transition is terminal and a repeat is idempotent only when it names the same outcome and post URN. Conflicting evidence is refused; the endpoint records the administrator UID and never calls LinkedIn's create API. Provider credentials are not stored in Library documents, application logs, callback URLs or browser storage.

Bluesky publishing connection​

Bluesky is a Connect provider only; it is refused by the generic sign-in route. IAM performs AT Protocol OAuth with PAR, PKCE S256, DPoP, private_key_jwt, a browser-bound one-use transaction, and issuer/subject and DID/PDS binding checks. Discovery resolves only public HTTPS destinations and rejects local, private and reserved addresses.

Local development has one explicit exception, never an inferred private-host allow-list. Start the pinned, loopback-only PDS and the matching IAM mode from the services checkout:

cd k8s/services/iam
make bluesky-pds-up
make bluesky-local-iam

This sets DATALAYER_BLUESKY_LOCAL_PDS_ORIGIN to the exact PDS-advertised http://localhost:2583 origin and uses AT Protocol's portless http://localhost virtual client metadata. That development client is native/public and therefore sends no private_key_jwt; PAR, PKCE, DPoP, one-use state, callback-cookie binding and the reviewed scopes still apply. Near-match ports, localhost/IP substitutions, paths, credentials and every other private destination remain refused. Stop and erase the disposable PDS with make bluesky-pds-down.

The client advertises this generated least-privilege scope and no compatibility fallback:

atproto blob:image/* repo:ai.datalayer.library.publication repo:app.bsky.feed.post

The repository scopes mean create, update and delete. Update is required for idempotent putRecord retries with deterministic keys. The checked-in artifact and generator pin @atproto/oauth-scopes@0.5.0; IAM rejects partial or broader grants and any refresh that changes the reviewed permission set. transition:generic is deliberately disabled because it would permit unrelated repository writes.

The public client metadata, JWKS and callback are exact endpoints below https://datalayer.ai/bluesky/oauth/. Versioned client signing keys and user sessions are encrypted under datalayer/iam/social/bluesky; neither appears in user secrets, callback URLs, logs or browser storage. The Library can call only three narrow operations for the authenticated caller: upload a blob, write a deterministic record, and delete a deterministic record. The only allowed collections are app.bsky.feed.post and ai.datalayer.library.publication.

Library is the only caller of the repository-operation endpoints. It uses its dedicated social:operate API key and a 30-second, one-use signed delegation bound to user, share and exact operation. IAM rejects replay, claim swapping, X-Forwarded-User, and browser bearer tokens at that boundary. Access tokens are refreshed before expiry under a per-session lock so a rotating refresh token is never used concurrently. DPoP nonce challenges are retried once with a new proof and the new nonce is persisted.

Replay protection is shared across IAM replicas. IAM writes only a SHA-256 digest of the delegation nonce to the iam Solr collection using Solr's create-only optimistic-concurrency condition. Exactly one request can consume a nonce; expired markers are removed by a server-side expiry predicate, and a Solr error fails the social operation closed. There is no process-local fallback.

Rotate the confidential-client signing key with an overlap:

datalayer-iam bluesky key-status
datalayer-iam bluesky rotate-key
# Wait at least the published JWKS cache lifetime and verify the new kid is used.
datalayer-iam bluesky retire-key <old-kid>

rotate-key stores a new versioned private key and keeps both public keys in JWKS. retire-key refuses the current key; use it only after authorization servers have refreshed the five-minute JWKS cache.

Required deployment values are:

  • DATALAYER_BLUESKY_CLIENT_ID=https://datalayer.ai/bluesky/oauth/client-metadata.json;
  • DATALAYER_BLUESKY_REDIRECT_URI=https://datalayer.ai/bluesky/oauth/callback;
  • the public ingress route /bluesky/oauth forwarded to IAM.

Disconnect attempts the discovered authorization-server revocation endpoint, then deletes the Vault session and provider link even if remote revocation is unavailable. Existing repository records are not implicitly removed; explicit unpublish owns that separate lifecycle.