feat(auth): populate tenant_chain at login + active-tenant middleware (#67)#68
Merged
Conversation
…scope (#67) Before this change `/auth/login` minted PASETO tokens that did not carry a `tenant_chain` custom claim. The `@tenant` guard then silently no-oped for every non-`platform_admin` user, because the Cedar adapter and the query-time scope reader both received an empty chain. A stock-config multi-tenant deployment let any authenticated caller see every tenant. Token contract is now: `tenant_chain` carries the user's flat set of `TenantMembership` rows. Active tenant selection is per-request via `X-Active-Tenant: <schema>:<entity_id>`, validated against the token and walked up the `@tenant(parent:)` hierarchy by a new middleware. The query-time scope reader switches from `_tenant == chain.last()` to `_tenant IN <chain>` so it agrees with Cedar's `_tenant in principal` parent semantics. Zero memberships under enabled tenancy now fails closed with 401 "no tenant assigned"; `platform_admin` keeps its documented bypass. Closes #67.
Contributor
Author
Smoke test — live verified ✅Built All seven scenarios produced exactly the expected result:
Additional confirmations from the run:
Checking the PR test plan items:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #67. End-to-end multi-tenant login:
/auth/loginnow ships the user'sTenantMembershiprows in the PASETOtenant_chainclaim, a newtenant_scopemiddleware validates anX-Active-Tenantheader per request and walks the@tenant(parent:)hierarchy, and the query-time scope filter aligns with Cedar's_tenant in principalparent semantics. Zero memberships under enabled tenancy fails closed (401), with the documentedplatform_adminbypass intact.Why the bigger change vs. the literal #67 fix
The minimum patch (populate the claim) would have shipped two latent bugs:
TenantMembershiprows are flat, but the old query filter usedtenant_chain.last(). A multi-membership user would silently get scoped to whichever row the DB returned last.inject_tenant_scopefiltered_tenant == chain.last()while the Cedar adapter projected the full chain intoprincipal.parents. For anyOrganization → Departmenthierarchy, query-time and policy-time scoping already disagreed.This PR separates two concepts that were conflated in one claim:
Claims): the active tenant plus its ancestors, used by both_tenant in principaland the query-time filter.What changed
Backend (
schema-forge-backend)AuthStore::list_tenant_memberships(username) -> Vec<TenantRef>trait method.EntityAuthStore::with_tenant_membership_schemabuilder; queriesTenantMembershipbyFilter::eq(\"user\", DynamicValue::Ref(user_entity_id)).entity_to_tenant_ref. 7 new unit tests.Acton (
schema-forge-acton)DynAuthStoreboxed-future shim for the new method.routes/auth.rs::login+::refresh: read memberships, enforceenforce_tenant_membership_policy(tenancy off → ok;platform_admin→ ok; 0 memberships + tenancy on → 401 "no tenant assigned"; 1+ → ok), project intocustom.tenant_chain. 6 new unit tests, 4 new HTTP integration tests (auth_login.rs) with PASETO round-trip decoding to assert claim contents.middleware/tenant_scope.rs: readsX-Active-Tenant: <schema>:<entity_id>, validates against memberships, walks the hierarchy from active leaf to root usingTenantConfig+DynEntityStore, rewritesClaims.custom.tenant_chainto the effective walk. 11 unit tests covering all branches.access.rs::inject_tenant_scope:_tenant == chain.last()→_tenant IN <chain>(special-cased toFilter::Eqwhen chain has one entry). 2 new tests.CLI (
schema-forge-cli)serve.rs::build_versioned_routes: layers thetenant_scopemiddleware between the acton-service token middleware and the forge handlers. ThreadsTenantConfig(asArc<Option<TenantConfig>>extension) and theTenantMembershipschema (intoEntityAuthStore) throughservestartup.Docs
docs/principal-claims-reference.md§10: flat memberships in the token vs. effective scope per request,X-Active-Tenantcontract, zero-membership policy, hierarchy walk mechanics, worked example.Test plan
cargo clippy -p schema-forge-backend -p schema-forge-acton -p schema-forge-cli --all-targets→ 0 warningscargo nextest run -p schema-forge-backend -p schema-forge-acton -p schema-forge-cli→ all pass (currently 867 tests, 0 failures)org-a→/auth/login200,/schemas/Opportunity/entitiesreturnsorg-arows only/schemas/Opportunity/entitieswithout header → 400ACTIVE_TENANT_REQUIREDX-Active-Tenant: Organization:not-his-org→ 403ACTIVE_TENANT_FORBIDDEN/auth/login401 "no tenant assigned"platform_admin→/auth/login200, empty chain (bypass intact)Out of scope (follow-ups)
_tenantfield's storage representation in hierarchical-tenancy entities (today the leaf id; persisting ancestors is a separate question).X-Active-TenantUI affordance in the generated React site (this PR ships only the API contract; the site can default to "first membership" until a tenant-switcher is designed).auth.login.failednoise on public routes) — adjacent but a separate fix.