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:
- A grant Maya holds in Acme never applies to a Globex resource, even while she’s a member of both.
- 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:annotategrants 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.