feat(auth): add GET /auth/me principal endpoint (#70)#72
Merged
Conversation
Surfaces the resolved principal to clients that cannot decrypt the PASETO: user_id (the User entity id), email, display_name, roles, the user's full tenant membership set (tenant_chain), the active tenant, and the projected principal_claims. This is the client-side anchor for 'signed in as / current org' chrome and a tenant switcher, removing the racy email->user_id lookup. Scope decision — switch-tenant uses what #67 shipped: #67 made the active tenant a per-request 'X-Active-Tenant: <type>:<id>' header resolved by the tenant_scope middleware, not a token claim. Switching tenants therefore needs no new token and no /auth/switch-tenant endpoint: the client sends a different header. /auth/me reports active_tenant by honoring that same header (valid member -> that tenant; sole membership -> implied; else null), and returns the header name so clients don't hard-code it. - tenant_scope middleware now skips /forge/auth/* so /auth/me returns the FULL membership set (the multi-membership header requirement would otherwise 400 exactly the users it serves); this also keeps /auth/refresh usable for multi-membership users. - resolve_active_tenant is a pure, unit-tested helper; parse_active_tenant reused from tenant_scope (no duplicate grammar). - Tests: 7 unit (resolver + serde shape) + 4 integration (full membership set, header-resolved active tenant, non-member header -> null, no-claims -> 401). No version bump (release bumps land separately, per #65). No public trait changes — reuses the list_tenant_memberships added in #67.
Add §10.7 to principal-claims-reference.md covering /auth/me as the client-side read of the X-Active-Tenant contract: full membership set, header-mirrored active_tenant resolution, tenant_scope exemption, and that switching uses the shipped header (no switch-tenant endpoint / no active-tenant claim). Cross-link from §10.2.
rrrodzilla
added a commit
that referenced
this pull request
May 28, 2026
The tenant_scope middleware exempted identity endpoints from tenant
scoping with an unanchored substring test (`path.contains("/forge/auth/")`).
A crafted entity path that merely contained that substring — e.g.
`/forge/Account/forge/auth/me` — or a prefix+traversal path such as
`/forge/auth/me/../Account/123` would skip tenant scoping and reach
tenant-scoped data unscoped.
Replace the substring check with an exact-match allowlist of the three
known identity routes, extracted as a pure `is_tenancy_exempt` function
so the security-relevant decision is directly unit-testable. Add tests
covering the substring and traversal bypass vectors; the prior early-out
had no regression coverage because the integration harness does not layer
this middleware.
Follow-up to #70/#72.
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.
Closes #70.
What
Adds
GET /api/v1/forge/auth/me— the authenticated principal as the server resolves it:{ "user_id": "user_01k...", "email": "roland@govcraft.ai", "display_name": "Roland Rodriguez", "roles": ["admin"], "tenant_chain": [{ "schema": "Organization", "entity_id": "organization_01k..." }], "active_tenant": { "tenant_type": "Organization", "tenant_id": "organization_01k..." }, "active_tenant_header": "x-active-tenant", "principal_claims": { } }This is the client-side anchor #70 asks for: a browser can't decrypt the PASETO, so it had no way to learn its own
user_id, its memberships, or the active tenant without a racyemail → user_idlookup./auth/mereturns all of it directly.Scope decision —
switch-tenantuses what #67 already shippedThe issue (written before #67 landed) also requested
POST /auth/switch-tenantthat mints a fresh PASETO carrying anactive_tenantclaim. #67 chose a different, already-shipped model: the active tenant is a per-requestX-Active-Tenant: <type>:<id>header, resolved by thetenant_scopemiddleware against the user's memberships. Under that model:/auth/mereportsactive_tenantby honoring that same header: a valid member → that tenant; a sole membership → implied; otherwisenull(the client must choose). It also returnsactive_tenant_headerso clients don't hard-code the name.If a sticky token-claim model is later preferred over the header, that's a separate follow-up; this PR deliberately avoids a second source of truth for active-tenant.
Notable change
tenant_scopemiddleware now skips/forge/auth/*. Necessary because/auth/memust return the full membership set, but the middleware refuses multi-membership requests that lackX-Active-Tenant(400ACTIVE_TENANT_REQUIRED) — i.e. it would reject exactly the users/auth/meexists to serve. This also quietly fixes/auth/refresh, which carries no tenant context of its own, for multi-membership users.Tests
resolve_active_tenant(empty / sole / multi / header-selected / non-member / malformed) +MeResponseserde shape.auth_login.rs): full membership set returned; active tenant resolved from header; non-member header →null; no claims → 401.cargo clippy -p schema-forge-acton --all-targetsclean; full crate suite green (460 passed).Out of scope / notes
list_tenant_membershipsadded in auth/login does not populate tenant_chain from TenantMembership rows #67.--all-featurescan't be exercised in my env (the FIPSaws-lc-fips-sysC build fails locally, unrelated to this change); validated with default features incl.surrealdb.