Articles · Enterprise SSO & Federation

Multi-org membership & delegated admin

Try out the running example

Most identity systems bolt multi-tenancy on: a tenant_id column, a middleware filter, and a prayer that no controller forgets it. Sentinel builds the org model into the subject itself — and puts the admin fence inside the authorization evaluator, where a forgotten filter can’t exist.

The model

Realm → Organizations → Teams, plus Groups as an orthogonal aggregation axis. A user belongs to exactly one realm and zero or more organizations via OrganizationMembership rows. Let’s build the classic consultant scenario — one human, two clients:

var realm  = await stores.CreateRealmAsync("default");
var acme   = await stores.CreateOrganizationAsync(realm, key: "acme");
var globex = await stores.CreateOrganizationAsync(realm, key: "globex");

var maya = await stores.CreateUserAsync(realm, "maya@consultancy.example");
await stores.AddMembershipAsync(maya, acme);
await stores.AddMembershipAsync(maya, globex);

Org context lives in the token

Maya logs in and picks a context — or logs in without one and gets her memberships back to choose from:

POST /auth/login
{ "email": "maya@consultancy.example", "password": "", "organizationId": "…acme…" }

The minted access token carries "org": "…acme…", and — this is the part that keeps authorization coherent — her permission snapshot is computed for (maya, acme). Grants scoped to Globex simply aren’t in it. Switching to Globex is an org-switch flow: mint a new token for the other org, no re-authentication, new snapshot. There is no “current org” server-side session variable to desynchronize; the token is the context.

Sessions, meanwhile, are realm-level and app-agnostic — one session can back tokens for either org, which is what makes the switch cheap.

The cross-org rule

Recall from article 002 how grants scope: an org-scoped grant applies when its org matches the effective org of the check — resourceOrganizationId ?? subject.organizationId. Two consequences do the tenancy work:

  1. A grant Maya holds in Acme never applies to a Globex resource, even while she’s a member of both.
  2. When a check names a resource org different from the token org, even realm-wide (org-less) grants stop applying. Holding a broad grant in one context doesn’t let you reach sideways into another org’s data.

Both behaviors are golden-vectored — they’re semantics, not middleware.

Delegated admin: the fence is structural

Now give each org an admin. The permission is sentinel:org:manage; the interesting question is what stops Acme’s admin from managing Globex.

The Node-generation sentinel answered “a controller-level check” — and its own retrospective calls that its #1 weakness, because every new endpoint was a chance to forget the org filter. Sentinel moves the check into the domain layer: SentinelAdminService resolves the target’s org and hands it to the evaluator as the resource org:

// Inside every admin operation, conceptually:
var check = new AccessCheck(
    PermissionId.Parse("sentinel:org:manage"),
    resourceOrganizationId: targetUser.OrganizationId);   // the TARGET's org

if (!AuthorizationEvaluator.Evaluate(callerSnapshot, check).IsAllowed)
    return AdminResult<T>.Forbidden();

Acme’s admin holds sentinel:org:manage scoped to Acme. Against a Globex target, the grant’s org doesn’t match the effective org → not applicable → DeniedByDefault. There is no controller where a forgotten comparison could reintroduce cross-org access, because no controller does the comparison — the same evaluator that guards your app guards the admin surface. Realm-wide admins hold the distinct sentinel:global:manage.

Try it against the admin API:

GET /sentinel-admin/orgs/{globexId}/users
Authorization: Bearer {acme-admin-token}
→ 403 (evaluated in the domain layer, not filtered in the query)

Org discovery for login routing

Enterprise users don’t pick orgs from dropdowns — their email does it. Organizations claim domains (OrganizationDomain, kind EmailDomain or Subdomain), and only verified domains participate:

POST /auth/idp/discover
{ "email": "ada@acme-health.example" }
→ { "organizationId": "…acme…", "identityProviderKey": "acme-entra" }

Your login page calls discover first; if an org with a default IdP comes back, you short-circuit to GET /auth/idp/acme-entra/start and the user never sees a password field. See enterprise SSO for the federation half.

Debugging: why can Maya see this?

Delegated admin questions are exactly what the inspector is for:

POST /sentinel-admin/authz/inspect
{ "userId": "…maya…", "organizationId": "…acme…",
  "permission": "records:org:read_chart", "resourceOrganizationId": "…globex…" }

The response lists every grant with its outcome (ScopeNotApplicable, Denied, …) against a fresh snapshot — cache excluded by design. When a customer asks why their admin can’t see something, the answer is a query away, with provenance (role:…, group:…) attached to every line.

Where this goes next

  • Teams add the third tier: records:team:annotate grants that apply only where the resource’s teams intersect the subject’s.
  • SCIM provisioning (enterprise docs) writes into exactly this model, fenced per-org by its bearer tokens.
  • Every admin mutation you just made left a before/after entry in the tamper-evident audit chain — see operations.