A ctlplne studio product

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:

  1. an IdP can provision and — critically — deprovision users, and a deprovision revokes access immediately;
  2. 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 a userName eq filter plus startIndex/count), GET/{id}, PUT/{id}, PATCH/{id}, DELETE/{id}.
  • Groups — POST, GET (list with a displayName eq filter, the lookup an IdP runs before binding members — DPR-041), GET/{id}, PATCH/{id} (member add/remove/replace, plus displayName rename — 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-tokens lists token metadata only;
  • POST /v1/directory/scim-tokens creates 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.write when department=contractor;
  • "step-up MFA for incident changes" — deny incident.write when mfa=false;
  • "delegated admin within an org" — a role granted at an org, team or project scope (POST /v1/directory/users/{id}/roles with scope_type/scope_id, or Scope in Admin → Identity → People & roles; see admin.md) administers only that branch, and a resource-scope policy on org can 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.

Rendered live from github.com/ctlplne/probectl — found a mistake? edit this page.