Enterprise identity: SCIM 2.0 + ABAC + directory integration
SCIM (System for Cross-domain Identity Management) is the standard protocol an identity provider uses to push user and group changes into an application — hire someone, they appear; fire someone, they vanish. RBAC is role-based access control: a user holds roles, roles grant permissions. ABAC is attribute-based access control: rules about properties of the user or the resource ("is a contractor", "has MFA") layered on top of those roles. An IdP (identity provider — Okta, Microsoft Entra ID, Keycloak, …) is the system that owns the user directory and does the pushing.
probectl extends the SSO/RBAC foundation with SCIM 2.0 user/group lifecycle provisioning, ABAC (attribute policies layered over RBAC), and the directory-integration path that Entra ID / Okta actually use (SCIM push + OIDC SSO).
Two ideas carry the whole feature:
- an IdP can provision and — critically — deprovision users, and a deprovision revokes access immediately;
- an attribute policy can narrow what an RBAC role grants — never widen it.
Where this sits relative to login: OIDC SSO (see
auth/self-hosted-idp.md) gets a user in the
door; SCIM is what assigns and removes their access.
probectl does not read group claims off the login token — group membership
arrives via SCIM. Why the split? A login token is a snapshot minted at sign-in;
a SCIM push is the directory speaking now. Access driven by SCIM changes the
moment HR changes, not the next time someone happens to log in.
SCIM 2.0 provisioning
The IdP calls /scim/v2/* with a per-tenant SCIM bearer token (a bearer
token is a secret string whose mere possession authenticates the request).
Like sessions and MCP tokens, the lookup is pre-tenant — the token selects
its own tenant, and only the token's hash is stored, so reading the database
can never recover (or mint) a usable token. Only an exact-hash
SECURITY DEFINER function can perform that lookup; direct application-role
access without a tenant GUC returns zero rows. All provisioning is then
tenant-scoped by row-level security (RLS — the database itself refuses to
return or write rows outside the current tenant, regardless of what the query
asks for), so one tenant's IdP can never touch another tenant's directory.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
IDP["IdP (Okta / Entra)"] -->|"Bearer <scim-token> (TLS)"| AUTH["scim auth\nhash → tenant (pre-tenant)"]
AUTH -->|"verified"| OPS["/scim/v2/Users + /Groups\n(tenant-scoped, RLS)"]
OPS --> USERS[("users\n(external_id, userName, active, attributes)")]
OPS --> ROLES[("roles + role_bindings\n(Group = role, members = bindings)")]
IDP -->|"PATCH active=false / DELETE"| DEPROV["deprovision"]
DEPROV --> REVOKE["revoke sessions + tokens\nIMMEDIATELY"]
REVOKE --> SESS[("sessions / mcp_tokens")]
Endpoints — twelve routes, mounted at /scim/v2, outside the /v1 API, in
internal/control/scim.go:
- Users —
POST(provision),GET(list with auserName eqfilter plusstartIndex/count),GET/{id},PUT/{id},PATCH/{id},DELETE/{id}. - Groups —
POST,GET(list with adisplayName eqfilter, the lookup an IdP runs before binding members — DPR-041),GET/{id},PATCH/{id}(member add/remove/replace, plusdisplayNamerename — the role's display name changes while its slug, the stable identity bindings join on, does not),DELETE. A group PATCH is atomic: it runs in one database transaction, so a failed sync applies nothing and returns a real error for the IdP to retry — never a 200 over half-applied state. A SCIM Group maps to a probectl role, and group membership is a role binding — that is the group-sync mapping that gives users their permissions. - Discovery —
GET /scim/v2/ServiceProviderConfig(the machine-readable capability sheet an IdP reads before provisioning).
Conformance details, because IdPs are strict: responses use the
application/scim+json media type and the SCIM error envelope
(urn:…:Error, with the HTTP status carried as a string); a group or user
pushed over an existing name is 409 with scimType uniqueness (never a
400, which an IdP treats as a permanent schema error — DPR-042); 201 on create,
409/uniqueness on a duplicate userName, 404 for an unknown id, 204 on
delete. PATCH deliberately tolerates the divergent ways IdPs encode
"deactivate" — Okta's valueless replace carrying {"active":false}, and
Entra's path:"active" with the string "False" (the parser in
internal/scim/patch.go accepts both a JSON bool and a quoted string). Two
IdPs, one spec, two dialects — the parser speaks both rather than failing the
one you happen to run.
Deprovision → immediate revocation
This is the part to get right. When a user is deactivated
(active=false via PATCH or PUT) or DELETEd, probectl deletes all of
that user's sessions and revokes their MCP tokens in the same request
(revokeUserAccess in internal/control/scim.go). The next request on a
deprovisioned session fails to resolve and returns 401 — there is no TTL
window to wait out. This does not depend on any cache; it is a direct session
delete keyed by (tenant_id, user_id), so it takes effect at once.
Think of a building badge: the safe design confiscates the badge at the desk the moment HR terminates, rather than letting it keep opening doors until its printed expiry date. A session that outlives the employment it represents is exactly the access an offboarding process exists to kill.
Minting a SCIM token
An operator mints the per-tenant bearer token from Admin & Settings → Identity administration or from the session-authenticated API:
GET /v1/directory/scim-tokenslists token metadata only;POST /v1/directory/scim-tokenscreates a token and returns the plaintext bearer value once;DELETE /v1/directory/scim-tokens/{id}revokes it.
The IdP then pastes the token into its provisioning config. The token is shown once — only its hash is stored, so this is the single chance to copy it. The bootstrap CLI still exists for first-boot/operator workflows:
probectl-control scim-token --tenant <tenant-uuid> --name okta
ABAC over RBAC
ABAC is a third check, after the tenant boundary and after RBAC. All three
layers are evaluated in that order by a single function, auth.Authorize
(internal/auth/abac.go) — the tenant boundary fails closed on a cross-tenant
resource before RBAC is consulted, so a stronger inner grant can never override
the outer boundary. The MCP tool layer routes its decision through it; the
table-driven matrix in internal/auth/authz_matrix_test.go covers in-tenant
grants, the RBAC baseline, ABAC overlays, cross-tenant denial, and
least-privilege provider-role denial. The mental
model: RBAC is the baseline grant; ABAC can only take away from it. RBAC
says "this role may write tests"; ABAC can add "…but not if the subject is a
contractor." The model is deny-override — an allow policy is just a silent
permit (RBAC already permitted), so ABAC can never widen access beyond what
RBAC granted (internal/auth/abac.go). Like a checkpoint guard who can
confiscate keys but carries none to hand out: whatever the guard decides, nobody
leaves holding a key RBAC didn't already issue. Why build it one-way? A policy
language that can grant is a second, parallel permission system — two places
to audit, two places to get wrong. One that can only narrow is pure
subtraction from a single source of truth.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart TD
REQ["request → resolved principal\n(tenant, permissions, attributes)"] --> T{"tenant boundary"}
T -->|ok| RBAC{"RBAC: has permission?"}
RBAC -->|no| D403a["403"]
RBAC -->|yes| ABAC{"ABAC: any policy denies?\n(subject + resource attrs)"}
ABAC -->|deny| D403b["403 (denied by policy)"]
ABAC -->|silent / allow| OK["handler runs"]
A Policy applies to a permission (or * for any) and matches when every
listed subject attribute and every listed resource attribute equals the
request's value. Among matching policies the highest priority decides, and a
deny wins ties. Every denial an attribute policy produces is written to the
tenant's tamper-evident audit stream as abac.denied (target = the
permission, data = permission, user id) — the audit page and the SIEM export
show who a policy stopped, not only that a policy exists. Repeats by the same
user for the same permission within one minute fold into the first row so a
looping client cannot grow the chain without limit (DPR-043). Subject
attributes come from the user's SCIM-provisioned
attributes (e.g. department) plus a derived mfa flag — both attached to
the principal at request time (loadSubjectAttributes in
internal/control/auth.go). The mfa key is reserved: probectl assigns it
from trusted authentication state after copying directory attributes, so a
SCIM value can neither forge MFA nor downgrade a verified MFA session. So
policies can express things like:
- "contractors cannot write" — deny
test.writewhendepartment=contractor; - "step-up MFA for incident changes" — deny
incident.writewhenmfa=false; - "delegated admin within an org" — a role granted at an
org,teamorprojectscope (POST /v1/directory/users/{id}/roleswithscope_type/scope_id, or Scope in Admin → Identity → People & roles; seeadmin.md) administers only that branch, and a resource-scope policy onorgcan narrow it further.
Hierarchy reads and writes provide the resolved resource attributes to ABAC as
tenant, org, team, and project. Parent resources are resolved inside the
caller's tenant scope first; RBAC lineage is checked second; then a matching
resource deny hides that branch from GET /v1/hierarchy or rejects the write.
An ancestor deny also covers descendants because their resource map retains the
ancestor identifiers.
Policies are managed at Admin & Settings → Identity administration and at
/v1/abac/policies (GET gated by directory.read; POST and DELETE by
directory.write) and cached per tenant for a short TTL (30 seconds), with
policy CRUD invalidating that tenant's cache
(internal/control/abac.go). The cache is a performance shortcut only —
because deprovision deletes sessions directly, a deprovisioned user is locked
out at once regardless of any cached policy. The worst a stale cache can do is
apply a 30-second-old policy to a still-valid user; it can never resurrect a
revoked one. Local invalidation advances a per-tenant cache generation after
the policy mutation commits. A store read that began under an older generation
is discarded instead of receiving a fresh TTL; the cache retries once against
the committed generation, then returns 503 unavailable (MCP:
authorization policy is temporarily unavailable) if policy churn invalidates
that retry too. In a multi-replica control plane, CRUD invalidates the local
replica immediately and other replicas refresh no later than that TTL. Once an
entry expires, a failed database refresh also returns 503 unavailable and
the protected handler does not run; an expired empty or allow policy set is
never treated as authority.
Directory connectors
Entra ID and Okta integrate via the standard path probectl already supports: OIDC SSO (per-tenant IdP) for login, and SCIM push for lifecycle and group sync. No additional connector is needed for these IdPs.
Permissions added
directory.read / directory.write gate the directory-admin surface — SCIM
tokens, ABAC policies, and the user/group lifecycle. They are seeded to the
admin role (migration 0018_scim_abac.sql) and are only ever honored
tenant-wide: an administrator delegated one org, team or project administers
that branch and never the tenant's directory.
Security guardrails upheld
- Tenant-first, then RBAC, then ABAC on every protected path.
- Immediate revocation on deprovision — no stale sessions or tokens.
- Pre-tenant bearer with hash-only token storage; a cross-tenant SCIM call is rejected.
- TLS on the API; SCIM bodies are size-limited and validated as untrusted input.
- Audited — every provision/update/deprovision and every policy change writes an audit event.
Without SCIM: in-product people & roles
Tenants without an IdP push still manage membership inside the product (DPR-027):
Admin → Identity → People & roles, /v1/directory/users and /v1/directory/roles,
and probectl directory … list users with their roles, create a person before
their first login, and grant or revoke a role — all tenant-scoped, RBAC-gated
by directory.read/directory.write, audited, and refusing to remove a
tenant's last administrator. SCIM group sync and manual bindings share the same
role_bindings rows, so a SCIM re-add of a group member simply restores the
binding. See admin.md.
Out of scope (deferred)
LDAP/AD pull connectors and SAML (OIDC is implemented; Entra/Okta use SCIM push + OIDC); SCIM bulk/sort/etag; and full resource-attribute plumbing into every handler (the ABAC resource model and role-binding scopes exist, but wiring per-handler resource attributes is incremental). BYOK/governance is tracked separately as an Enterprise-tier feature.