Skip to content

feat(auth): populate tenant_chain at login + active-tenant middleware (#67)#68

Merged
rrrodzilla merged 1 commit into
mainfrom
feat/67-multi-tenant-login
May 28, 2026
Merged

feat(auth): populate tenant_chain at login + active-tenant middleware (#67)#68
rrrodzilla merged 1 commit into
mainfrom
feat/67-multi-tenant-login

Conversation

@rrrodzilla
Copy link
Copy Markdown
Contributor

Summary

Closes #67. End-to-end multi-tenant login: /auth/login now ships the user's TenantMembership rows in the PASETO tenant_chain claim, a new tenant_scope middleware validates an X-Active-Tenant header per request and walks the @tenant(parent:) hierarchy, and the query-time scope filter aligns with Cedar's _tenant in principal parent semantics. Zero memberships under enabled tenancy fails closed (401), with the documented platform_admin bypass intact.

Why the bigger change vs. the literal #67 fix

The minimum patch (populate the claim) would have shipped two latent bugs:

  1. Token ambiguityTenantMembership rows are flat, but the old query filter used tenant_chain.last(). A multi-membership user would silently get scoped to whichever row the DB returned last.
  2. Query-vs-policy disagreementinject_tenant_scope filtered _tenant == chain.last() while the Cedar adapter projected the full chain into principal.parents. For any Organization → Department hierarchy, query-time and policy-time scoping already disagreed.

This PR separates two concepts that were conflated in one claim:

  • Memberships (flat, identity-level, in the token): the set of tenants the user belongs to.
  • Effective scope (hierarchical, request-level, in mutated Claims): the active tenant plus its ancestors, used by both _tenant in principal and the query-time filter.

What changed

Backend (schema-forge-backend)

  • New AuthStore::list_tenant_memberships(username) -> Vec<TenantRef> trait method.
  • EntityAuthStore::with_tenant_membership_schema builder; queries TenantMembership by Filter::eq(\"user\", DynamicValue::Ref(user_entity_id)).
  • Pure-function helper entity_to_tenant_ref. 7 new unit tests.

Acton (schema-forge-acton)

  • DynAuthStore boxed-future shim for the new method.
  • routes/auth.rs::login + ::refresh: read memberships, enforce enforce_tenant_membership_policy (tenancy off → ok; platform_admin → ok; 0 memberships + tenancy on → 401 "no tenant assigned"; 1+ → ok), project into custom.tenant_chain. 6 new unit tests, 4 new HTTP integration tests (auth_login.rs) with PASETO round-trip decoding to assert claim contents.
  • New middleware/tenant_scope.rs: reads X-Active-Tenant: <schema>:<entity_id>, validates against memberships, walks the hierarchy from active leaf to root using TenantConfig + DynEntityStore, rewrites Claims.custom.tenant_chain to the effective walk. 11 unit tests covering all branches.
  • access.rs::inject_tenant_scope: _tenant == chain.last()_tenant IN <chain> (special-cased to Filter::Eq when chain has one entry). 2 new tests.

CLI (schema-forge-cli)

  • serve.rs::build_versioned_routes: layers the tenant_scope middleware between the acton-service token middleware and the forge handlers. Threads TenantConfig (as Arc<Option<TenantConfig>> extension) and the TenantMembership schema (into EntityAuthStore) through serve startup.

Docs

  • docs/principal-claims-reference.md §10: flat memberships in the token vs. effective scope per request, X-Active-Tenant contract, 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 warnings
  • cargo nextest run -p schema-forge-backend -p schema-forge-acton -p schema-forge-cli → all pass (currently 867 tests, 0 failures)
  • Smoke against issue repro: alice with 1 membership in org-a/auth/login 200, /schemas/Opportunity/entities returns org-a rows only
  • Smoke: bob with 2 memberships → /schemas/Opportunity/entities without header → 400 ACTIVE_TENANT_REQUIRED
  • Smoke: bob with X-Active-Tenant: Organization:not-his-org → 403 ACTIVE_TENANT_FORBIDDEN
  • Smoke: zero-membership non-admin → /auth/login 401 "no tenant assigned"
  • Smoke: zero-membership platform_admin/auth/login 200, empty chain (bypass intact)

Out of scope (follow-ups)

  • The _tenant field's storage representation in hierarchical-tenancy entities (today the leaf id; persisting ancestors is a separate question).
  • X-Active-Tenant UI 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).
  • Issue auth.login.failed emitted on every unauthenticated request, including public routes #66 (auth.login.failed noise on public routes) — adjacent but a separate fix.

…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.
@rrrodzilla
Copy link
Copy Markdown
Contributor Author

Smoke test — live verified ✅

Built cargo build --release, booted schemaforge serve against mem:// with @tenant(root) on Organization and @tenant(parent: "Organization") on Opportunity. Seeded: alice (1 membership: org-a), bob (2 memberships: org-a + org-b), carol (0 memberships, role member).

All seven scenarios produced exactly the expected result:

# Actor Header Expected Got
1 alice (absent, sole membership) GET /Opportunity/entities returns only org-a rows ✅ 2 rows, both _tenant=org-a
2 bob (multi) (absent) 400 ACTIVE_TENANT_REQUIRED ✅ 400 + correct envelope
3 bob X-Active-Tenant: Organization:<org-a> only org-a rows ✅ 2 rows, both _tenant=org-a
4 bob X-Active-Tenant: Organization:org-c-fake 403 ACTIVE_TENANT_FORBIDDEN ✅ 403 + correct envelope
5 carol (zero membership) login 401 no tenant assigned ✅ 401 + correct envelope
6 bob X-Active-Tenant: Organization:<org-b> only org-b rows ✅ 1 row, _tenant=org-b
7 alice X-Active-Tenant: Organization:<org-b> (impersonation) 403 ACTIVE_TENANT_FORBIDDEN ✅ 403

Additional confirmations from the run:

  • inject_tenant_on_create correctly stamps _tenant on writes: alice's POST with no header set _tenant=org-a; bob's POST with X-Active-Tenant: Organization:org-b set _tenant=org-b. Confirms the middleware narrows the effective chain before the create handler runs.
  • platform_admin bypass intact: admin login succeeds with zero memberships, and admin's GET returns all rows across tenants (no scoping).
  • auth.login.success audit event still fires correctly (visible in server log; unchanged from prior behavior).

Checking the PR test plan items:

  • cargo clippy -p schema-forge-backend -p schema-forge-acton -p schema-forge-cli --all-targets → 0 warnings
  • cargo nextest run ... → 867 passed, 0 failed
  • Smoke: alice with 1 membership → scoped to org-a only
  • Smoke: bob no header → 400 ACTIVE_TENANT_REQUIRED
  • Smoke: bob with X-Active-Tenant for a tenant he isn't in → 403 ACTIVE_TENANT_FORBIDDEN
  • Smoke: zero-membership non-admin → 401 no tenant assigned
  • Smoke: zero-membership platform_admin → 200, empty chain (bypass intact)

@rrrodzilla rrrodzilla merged commit 1146539 into main May 28, 2026
1 check passed
@rrrodzilla rrrodzilla deleted the feat/67-multi-tenant-login branch May 28, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth/login does not populate tenant_chain from TenantMembership rows

1 participant