Demo environment. Penrose Analytics is fictional, the demo people are shared, and the workspace resets nightly.

Gatehouse

SCIM contract

SCIM 2.0 (RFC 7643 and RFC 7644) for Users: create, read, list with a filter, patch the active flag and deactivate. Groups, bulk, sorting, ETags and password changes are not implemented, and the ServiceProviderConfig says so. The base URL of this deployment is https://auth.phoenixcodes.org/api/scim/v2.

Tokens

Every request carries Authorization: Bearer gh_scim_…. A workspace owner or admin creates a token under Single sign-on, Provisioning (the page asks the policy engine for scim:manage, which also needs a second factor). A token belongs to one workspace and can only act inside it.

  • 256 random bits behind the gh_scim_ prefix, so a secret scanner can recognise a leaked one.
  • Shown once. Only its SHA-256 is stored; a token that long needs no slow hash, and the lookup is a plain read of that hash.
  • Revoking takes effect on the next request. At most 5 tokens are active per workspace. Creating, revoking and every use that changes something are in the audit log.
SCIM provisioning and deprovisioning

Endpoints

Bodies are application/scim+json (plain application/json is accepted). Responses are never cached.

RequestDoesSuccess
GET /UsersList people. filter, startIndex (1-based) and count (default 100, at most 200).200, a ListResponse
GET /Users/{id}One person. The id is the membership id, so it is only valid in this workspace.200
POST /UsersCreate a person. The same userName twice is a conflict, not a duplicate.201 with a Location
PATCH /Users/{id}Change active, displayName (or name.formatted) and externalId.200 with the person
DELETE /Users/{id}Deactivate. The row, the history and the audit trail stay, and the person can be reactivated.204
GET /ServiceProviderConfig, /ResourceTypes, /SchemasWhat this service supports. No token needed; limited per address.200
curl -X POST https://auth.phoenixcodes.org/api/scim/v2/Users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
       "userName":"ada@acme.example","displayName":"Ada Lovelace",
       "externalId":"00u1abc"}'

The User resource

The identity is the email address, so userName is an address, is compared without regard to case and cannot be changed. Gatehouse keeps one display name per person, so name.formatted and displayName are the same string, and emails is returned with the address as the single primary work address (it is accepted and ignored on the way in).

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "mem_k3m9x2q7b4n8d1ya",
  "externalId": "00u1abc",
  "userName": "ada@acme.example",
  "name": { "formatted": "Ada Lovelace" },
  "displayName": "Ada Lovelace",
  "emails": [{ "value": "ada@acme.example", "type": "work", "primary": true }],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-10-23T09:14:03.000Z",
    "lastModified": "2026-10-23T09:14:03.000Z",
    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
  }
}

Filters

The subset a provisioning client sends to find one person or a few: comparisons joined by and and or, not(…), parentheses, and the operators eq ne co sw ew pr (pr means present). Names are case-insensitive, as the RFC says. The filter is parsed into a tree and turned into parameterised SQL; nothing is evaluated as code and nothing is concatenated into a query.

Filterable attributes: id, externalId, userName, displayName, name.formatted, emails.value, active. Value paths such as emails[type eq "work"], comparisons on numbers or dates and unknown attributes are refused with invalidFilter so a client learns it asked for something this service does not do.

userName eq "ada@acme.example"
active eq true and (userName sw "ada" or externalId pr)
not(userName co "contractor")

A filter is at most 400 characters, 20 comparisons and 8 levels deep. A test checks every filter against an in-memory evaluator and the SQL it becomes, and requires the two to agree.

PATCH

Both shapes real clients send work, and every operation is checked before any is applied, so a request with one bad operation changes nothing. At most 20 operations. op is add, replace or remove in any case; booleans may arrive as true or as the string "True", which is how Entra sends them.

{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
 "Operations":[{"op":"replace","path":"active","value":false}]}
{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
 "Operations":[{"op":"replace","value":{"active":false}}]}

userName, id and meta cannot be changed (mutability), and active and the names cannot be removed. Setting active to false twice is fine: the call is idempotent and the audit log records the deactivation once.

Rules

  • Deactivation is not deletion. active: false and DELETE do the same thing: the membership is deactivated, every session of the person is deleted in the same step, and the audit log records who did it. A person who is deactivated in every workspace they belong to is refused a session by every method (password, passkey, social, SSO).
  • A token never attaches an account that already exists. If userName belongs to someone who has an account, creating them is a 409. Otherwise whoever holds a token for a workspace could provision a stranger's address, then assert it through SSO, and walk into their account.
  • The last active owner cannot be deactivated, so a directory mistake cannot lock a workspace out of itself.
  • The shared demo workspace is protected. Its three demo people cannot be changed or deactivated, it accepts only addresses on the reserved .example domain, and it holds at most 25 provisioned people until its nightly reset.
  • People provisioned by SCIM show as scim on the Members page, and take the workspace's default role until a role is set; roles from SSO groups apply on their next SSO sign-in.

Errors

Failures are SCIM error bodies with status as a string and a scimType where RFC 7644 defines one. No stack trace or message from below the SCIM layer is ever included.

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "409",
  "scimType": "uniqueness",
  "detail": "An account with that userName already exists."
}
StatusscimTypeWhen
400invalidSyntax, invalidValue, invalidFilter, invalidPath, mutabilityThe body, filter or path is wrong, with a sentence saying which part.
401No token, a malformed one, or an unknown or revoked one. WWW-Authenticate: Bearer.
403A protected demo person, the last owner, or a full demo workspace.
404No such person in this workspace.
409uniquenessThe address already belongs to an account.
413, 415Body over 16 KB, or not JSON.
429tooManyOver a limit below. Retry-After says when.

Limits

  • 120 requests a minute per token (X-RateLimit-Limit and X-RateLimit-Remaining on every response).
  • 20 failed-token attempts a minute per address; past it the answer is 429, not 401.
  • 60 discovery requests a minute per address.
  • Request bodies over 16 KB are refused unread.