On this page

▶ English · Tiếng Việt · 2:32 · Script: demo-video-series.en.md

In one sentence: a company's employees sign in to Astra through their own SAML 2.0 IdP brokered by Keycloak, IdP groups become tenant roles in the OIDC token, and the company's directory creates, assigns roles to, disables and deletes those accounts over SCIM 2.0, only inside its own tenant. Read this if: you need to know what /scim/v2 accepts, how the tenant is bound to a SCIM token, and why the kernel has no SAML code (developer) / you are answering an enterprise customer's security questionnaire about SSO, joiner-mover-leaver and tenant isolation (tech lead).

The problem

When Astra is sold to a company rather than used by one, the company's IT team asks two questions before anyone signs in:

  1. "Can our staff use our IdP?" They will not let employees keep a second password in a vendor's system. Their IdP (Entra ID, Okta, ADFS…) speaks SAML 2.0, sometimes OIDC.
  2. "Who creates and removes accounts?" When someone joins, changes team or leaves, their HR-driven directory must push that change. Manual invitations do not pass an audit, and a forgotten leaver is a breach.

The usual answers are an SSO plugin inside the application (SAML parsing, certificate rotation and replay protection written by the app team) and a CSV import for users. Frappe offers social login and LDAP out of the box; SAML and SCIM usually come from third-party apps or a proxy.

Astra already delegates identity to Keycloak (Video 11). This video shows the consequence: SAML lives entirely in Keycloak's identity brokering, and Astra adds only a SCIM endpoint that edits the same /tenants/<tenant>/<role> groups the kernel already reads.

What the video shows

The demo tenant is acme. The IdP is a separate Keycloak realm, acme-corp-idp, standing in for the company's IdP. Its users linh and bao are not the dispatcher linh from Video 20.

Scene What you see What it proves
Login One button, "Acme Corp SSO". linh signs in at the IdP The password stays at the IdP; Astra receives an OIDC code.
Flow SP-initiated: Desk → Keycloak (kc_idp_hint=acme-corp) → AuthnRequest to the IdP → signed SAMLResponse → OIDC code The realms speak real SAML 2.0; Desk speaks only OIDC with Keycloak.
Assertion NameID linh, Audience urn:astra:sp, memberOf astra-editors, RSA-SHA256 signature on assertion and response. Token: preferred_username: acme-corp.linh, groups: ["/tenants/acme/editor"] Keycloak maps IdP groups to tenant roles; Astra reads the role from the token.
Desk Linh Vo signs in as acme editor and creates an Article (201) No SSO code in the kernel; DocType permissions apply as for local users.
Tamper bao's SAMLResponse is altered in transit (astra-readers → astra-admins); Keycloak answers 400 for an invalid signature No code is issued, so nobody signs in.
Discovery /scim/v2/ServiceProviderConfig: patch: true, filter maximum 200, bulk: false, etag: false The tenant comes only from the scim_tenant claim of the client's token, never from the request.
Create POST /scim/v2/Users → 201; repeat userName → 409 uniqueness; the new user gets 403 on /api/doc/Article New users have no role, so no rights yet.
Role PATCH adds the user to group editor; after a new sign-in the token has /tenants/acme/editor; Article → 201 Groups are roles. Changes apply at the next sign-in or refresh.
Deactivate PATCH {"op":"Replace","path":"active","value":"False"} → password grant invalid_grant "Account disabled", refresh "Session not active" Sign-in is blocked at once; an access token already issued stays valid until it expires.
Fail closed globex's token: acme user → 404, filter → 0 results, X-Astra-Tenant: acme → 403, adding an acme user to a globex role → 400 invalidValue, filter co → 400 invalidFilter Errors follow RFC 7644; another tenant's token sees nothing.
Delete DELETE → 204, GET → 404 Deleted users are gone.

Figure 2: Tampered assertion Cryptographic assertion verification: tampered SAML assertions or mismatched tenant tokens are rejected immediately.

How it works

SAML: brokered by Keycloak, invisible to the kernel

Diagram
Diagram; View source
View source
sequenceDiagram
    autonumber
    participant D as Desk (browser)
    participant K as Keycloak realm astra<br/>(SP, entityId urn:astra:sp)
    participant I as IdP realm acme-corp-idp
    participant A as Astra kernel
    D->>K: OIDC /auth (PKCE) + kc_idp_hint=acme-corp
    K->>I: SAML AuthnRequest (POST binding)
    I->>I: linh signs in (password stays here)
    I-->>K: SAMLResponse, signed (RSA-SHA256)<br/>NameID linh, memberOf astra-editors
    K->>K: verify signatures, map memberOf → /tenants/acme/editor<br/>user acme-corp.linh
    K-->>D: OIDC code
    D->>K: code + code_verifier
    K-->>D: access token, groups ["/tenants/acme/editor"]
    D->>A: POST /api/doc/Article (Bearer token)
    A-->>D: 201

The service-provider side is configuration in the astra realm. Signatures are required and verified; the AuthnRequest is not signed and assertions are not encrypted, which the "To be accurate" section repeats.

deploy/keycloak/realm/astra-realm.json (identity provider acme-corp, trimmed)

"alias": "acme-corp",
"displayName": "Acme Corp SSO",
"providerId": "saml",
"config": {
  "entityId": "urn:astra:sp",
  "postBindingAuthnRequest": "true",
  "postBindingResponse": "true",
  "wantAuthnRequestsSigned": "false",
  "validateSignature": "true",
  "wantAssertionsSigned": "true",
  "wantAssertionsEncrypted": "false",
  "signatureAlgorithm": "RSA_SHA256",
  "syncMode": "FORCE"
}

Group mapping is one Keycloak mapper per role. The IdP's memberOf value is matched exactly (no regex) and turned into the tenant role group:

deploy/keycloak/realm/astra-realm.json (identityProviderMappers, one of four role mappers)

{
  "name": "acme-corp editor",
  "identityProviderAlias": "acme-corp",
  "identityProviderMapper": "saml-advanced-group-idp-mapper",
  "config": {
    "syncMode": "INHERIT",
    "attributes": "[{\"key\":\"memberOf\",\"value\":\"astra-editors\"}]",
    "are.attribute.values.regex": "false",
    "group": "/tenants/acme/editor"
  }
}

A username mapper with template ${ALIAS}.${NAMEID} produces acme-corp.linh, so a brokered user can never collide with a local linh.

Why broker instead of embedding SAML in Astra: XML signature validation is a classic source of vulnerabilities, and Keycloak already handles metadata, certificate rotation, clock skew (allowedClockSkew: 30) and account linking. The kernel keeps a single trust path, an OIDC JWT, and the code in Video 11 works unchanged. The tamper scene is therefore a test of Keycloak's verification, which is the point: nothing in Astra had to be written for it.

Figure 1: SP-initiated SAML through a broker SP-initiated SAML flow: enterprise identity providers authenticate users through Keycloak without exposing SAML complexity to the kernel.

SCIM: the tenant comes from the token, never from the request

SCIM clients authenticate with client credentials of a per-tenant Keycloak client, scim-<tenant>. A hardcoded claim mapper on that client puts the tenant into the token:

deploy/keycloak/realm/astra-realm.json (client scim-acme, protocol mapper)

{
  "name": "scim tenant",
  "protocolMapper": "oidc-hardcoded-claim-mapper",
  "config": {
    "claim.name": "scim_tenant",
    "claim.value": "acme",
    "jsonType.label": "String",
    "access.token.claim": "true"
  }
}

ScimResource resolves the tenant only from that claim, requires the realm role scim, and refuses a conflicting tenant header:

astra-iam/src/main/java/io/astra/iam/ScimResource.java

    private String tenant() {
        if (identity.isAnonymous()) {
            throw ScimException.unauthorized("a bearer token of the tenant's SCIM client is required");
        }
        if (!identity.hasRole(SCIM_ROLE)) throw ScimException.forbidden("the token lacks the " + SCIM_ROLE + " role");
        Object claim = jwt.getClaim(TENANT_CLAIM);
        String tenant = claim instanceof JsonString s ? s.getString() : claim == null ? null : claim.toString();
        if (tenant == null || !tenant.matches(TENANT_ID)) {
            throw ScimException.forbidden("the token is not bound to a tenant (claim " + TENANT_CLAIM + ")");
        }
        String requested = headers.getHeaderString(TenantResolverFilter.TENANT_HEADER);
        if (requested != null && !requested.isBlank() && !requested.trim().equals(tenant)) {
            throw ScimException.forbidden("this SCIM token is bound to tenant " + tenant + " and cannot act on " + requested.trim());
        }
        if (!scim.tenantExists(tenant)) throw ScimException.forbidden("tenant " + tenant + " does not exist");
        return tenant;
    }

Why a claim and not a path like /scim/v2/acme/Users: a path is chosen by the caller. A claim set by Keycloak for one client cannot be changed by the caller, so a leaked scim-globex secret can never reach acme.

SCIM users and groups are Keycloak users and groups

ScimDirectory stores nothing of its own. Its class comment states the two rules that make tenant isolation work:

astra-iam/src/main/java/io/astra/iam/ScimDirectory.java

/**
 * SCIM users and groups of one tenant, stored in Keycloak through {@link KeycloakDirectory}'s admin client.
 *
 * <ul>
 *   <li>A tenant's SCIM users are the identities its provisioning client created: direct members of the
 *       tenant group {@code /tenants/<tenant>}. That membership grants no role; it marks ownership, so a
 *       tenant's SCIM client can neither see nor change local members or another tenant's users.</li>
 *   <li>A tenant's SCIM groups are its role groups {@code /tenants/<tenant>/<role>}. Group membership is
 *       the tenant role that reaches the user's token (claim {@code groups}) at the next sign-in.</li>
 * </ul>
 */

That explains the scenes: a new user is a member of /tenants/acme only, which Memberships ignores (it needs <tenant>/<role>), so the kernel answers 403 "not a member of tenant acme". A user not owned by the caller's tenant answers 404 rather than 403, so its existence is not revealed. Creating or deleting a group returns 501, because roles are defined by Astra's DocTypes, not by the directory.

Deactivation also ends the user's Keycloak sessions:

astra-iam/src/main/java/io/astra/iam/ScimDirectory.java

        if (in.password() != null) setPassword(user, in.password());
        if (wasActive && !Boolean.TRUE.equals(rep.isEnabled())) user.logout();
        return user(tenant, id);

The PATCH parser accepts what real clients send, including Entra's capitalised "Replace" and string booleans such as "False":

astra-iam/src/main/java/io/astra/iam/ScimPatch.java

    static Boolean bool(JsonNode v, String attribute) {
        if (v == null || v.isNull()) return null;
        if (v.isBoolean()) return v.booleanValue();
        if (v.isTextual() && (v.asText().equalsIgnoreCase("true") || v.asText().equalsIgnoreCase("false"))) {
            return Boolean.parseBoolean(v.asText().toLowerCase(Locale.ROOT));
        }
        throw ScimException.invalidValue(attribute + " must be a boolean");
    }
Diagram
Diagram; View source
View source
flowchart LR
    HR["Company directory<br/>(SCIM client scim-acme)"] -->|client credentials| KC["Keycloak<br/>token: scim_tenant=acme, role scim"]
    HR -->|"POST/PATCH/DELETE /scim/v2"| SR["ScimResource.tenant()"]
    SR --> SD["ScimDirectory"]
    SD -->|admin client| G1["/tenants/acme (ownership)"]
    SD -->|admin client| G2["/tenants/acme/editor (role)"]
    G2 -->|next sign-in| T["user token groups[]"]
    T --> K["Astra kernel: Memberships → DocPermissions"]

Real-world example

Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.

A Vietnamese media group runs its newsroom CMS as tenant acme on Astra. Its IT team manages 300 staff in a corporate directory that speaks SAML 2.0 and SCIM 2.0.

Joiner. HR hires Chi as an editor on Monday.

  • The directory calls POST /scim/v2/Users with userName: chi, then PATCHes group editor to add her.
  • Astra (through Keycloak) creates the account under /tenants/acme and adds it to /tenants/acme/editor.
  • Chi signs in with the company's credentials; her token carries /tenants/acme/editor; she can create Articles (editor: read, create, write in article.doctype.yaml).

Mover. In June she becomes a section reader only.

  • The directory PATCHes editor to remove her and reader to add her.
  • Her current access token still says editor until it expires; the next refresh carries reader, and Article creation then returns 403.

Leaver. On her last day at 17:00, HR disables her.

  • The directory sends PATCH active=False. Keycloak disables the account and ends her sessions.
  • 17:00:01: password and refresh grants fail. An access token issued at 16:58 is still accepted by Astra until it expires: at most 5 minutes later for a regular client with the realm's 300-second lifespan.
  • A month later the directory sends DELETE; GET returns 404.

Isolation. A sister company on tenant globex uses its own scim-globex client. Its directory cannot list, read, or add Chi to a role; every attempt returns 404, 0 results, 403 or 400 as in the video.

What the company has to supply for this to work in production is in the "To be accurate" list: its own IdP entry and group mappers in Keycloak, and a tested group naming convention.

Figure 3: SCIM joiner, mover, leaver inside one tenant Automated identity lifecycle: SCIM 2.0 synchronizes joiners, role changes, and departures directly into tenant group memberships.

For technical leaders

  • One trust path. The kernel trusts one thing, a Keycloak-signed JWT. SAML, local passwords and future brokers all end there, so security review of Astra's code does not grow with each IdP.
  • Revocation latency equals access-token lifetime. Astra verifies JWTs offline. With 300 seconds, a disabled user can keep using a token for up to 5 minutes. If that is unacceptable, shorten the lifespan or add token introspection, at the cost of a Keycloak call per request.
  • Per-customer onboarding is configuration, not code. Each company needs an identity provider entry and one group mapper per role in the realm, plus a scim-<tenant> client. That is work for an operator; automate it before you have dozens of enterprise tenants.
  • Scale limits. Listing users reads group members from Keycloak and paginates in memory, with one extra group read per user. Fine for hundreds or a few thousand users per tenant, not hundreds of thousands.
  • Usernames are realm-wide. create checks uniqueness across the whole Keycloak realm, so two tenants cannot provision the same userName. Prefer email-style usernames for SCIM.
  • What to measure. SCIM error rate by scimType (uniqueness, invalidFilter, invalidValue), time from HR leaver event to active=false, broker login failures per IdP (expired certificates show up here first).
  • When not to use this. If a customer requires IdP-initiated login, Single Logout, encrypted assertions or SCIM Bulk today, those are not built.
  • Compared with alternatives. A commercial identity platform gives the same broker plus a managed SCIM connector catalogue; Keycloak keeps everything self-hosted and open source, with more operator work.

Try it yourself

Prerequisites: the k3d or Docker Compose stack, plus python3 and openssl (the scene script checks for both), jq and Bash 5.

make k3d-up                       # or: make infra-up (runs scripts/keycloak-compose-dev.sh)
# If Keycloak was already running: scripts/keycloak-sync-realm.sh, then restart astra-server to get /scim/v2
make demo-sso PAUSE=0             # all eight scenes
make demo-sso SCENES="1 5"        # pick scenes

Expected, scene by scene (scripts/demo-sso.sh):

  1. Decoded assertion for linh; token claims preferred_username: acme-corp.linh, groups: ["/tenants/acme/editor"]; /api/iam/me shows acme [editor]; POST /api/doc/Article → 201.
  2. No authorization code for bao after his assertion is altered.
  3. ServiceProviderConfig and ResourceTypes; SCIM token claims include scim_tenant: acme.
  4. Create → 201, filter by userName → 1 result, duplicate → 409, Article → 403 for the role-less user.
  5. Groups list (admin, editor, author, reader); PATCH editor; new token has /tenants/acme/editor; Article → 201.
  6. active=False; password grant invalid_grant; refresh refused; old access token still answers, with its expiry printed.
  7. globex token: 404, 0 results, 403, 400 invalidValue; co filter → 400 invalidFilter.
  8. DELETE → 204, GET → 404.

The script deletes its SCIM user when it ends. The tests astra-server/src/test/java/io/astra/server/SamlBrokerTest.java and ScimTest.java, plus astra-iam/src/test/java/io/astra/iam/ScimFilterTest.java and ScimPatchTest.java, pin the behaviour; run make test.

The IdP passwords and the SCIM client secrets in the realm JSON files are for local environments only.

To be accurate

  • The IdP is a Keycloak realm (acme-corp-idp) in the same container, not Okta, Entra or ADFS; the SAML protocol between the two sides is real. The demo replays the browser steps with curl and does not follow the redirect back to Desk.
  • Assertion and response are signed and verified; the assertion is not encrypted and the AuthnRequest is not signed. Only SP-initiated login; IdP-initiated is not configured; Single Logout is not wired up and not tested.
  • On first login Keycloak creates the account acme-corp.<username>; if the email matches a local account, Keycloak asks to link them, which the demo does not show. The group → role mapping is fixed in the realm configuration and only for tenant acme; each other company needs its own IdP and mappers.
  • SCIM has no Bulk, ETag/meta.version, sort, /Me, meta.lastModified or attributes parameter yet; excludedAttributes only applies to Groups members. Filters support only userName eq and displayName eq. externalId is ignored; only one email is kept; userName cannot be changed.
  • Groups are fixed because they are the tenant roles: creating or deleting a group returns 501; only membership changes. A tenant's SCIM client only sees and edits users it provisioned. Deleting through SCIM also deletes the Keycloak account.
  • Role changes take effect from the next login or refresh. After active=false, an access token already issued stays valid until it expires (5 minutes for regular clients, 1 hour for the demo client astra-dev-cli).
  • Listing users paginates in memory with one extra group read per user: enough for the demo, not for hundreds of thousands of users.

Figures

Related

AI resources

Give your AI assistant the current company’s documentation, Markdown pages, and OpenAPI contract.

Source
Help me integrate with AstraCMS Docs (Không áp dụng).
Read https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/en/llms.txt first, then https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/en/llms-full.txt and https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/en/openapi.json.
Use only the documented endpoints, authentication, schemas and examples. Ask me about my application stack and the operation I need before writing code. Identify any documented limitations or inconsistencies. Keep credentials in environment variables, never in client-side code. Use the sandbox for examples; do not execute requests or change production data without my explicit approval.
Documentation locale: en. API language value: en.
Current page: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/en/sso.