Skip to content

feat(auth): add GET /auth/me principal endpoint (#70)#72

Merged
rrrodzilla merged 2 commits into
mainfrom
feat/auth-me-endpoint
May 28, 2026
Merged

feat(auth): add GET /auth/me principal endpoint (#70)#72
rrrodzilla merged 2 commits into
mainfrom
feat/auth-me-endpoint

Conversation

@rrrodzilla
Copy link
Copy Markdown
Contributor

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 racy email → user_id lookup. /auth/me returns all of it directly.

Scope decision — switch-tenant uses what #67 already shipped

The issue (written before #67 landed) also requested POST /auth/switch-tenant that mints a fresh PASETO carrying an active_tenant claim. #67 chose a different, already-shipped model: the active tenant is a per-request X-Active-Tenant: <type>:<id> header, resolved by the tenant_scope middleware against the user's memberships. Under that model:

  • Switching tenants needs no new token and no new endpoint — the client just sends a different header. (Per the maintainer steer: use what's shipped if it meets the use case.)
  • /auth/me reports active_tenant by honoring that same header: a valid member → that tenant; a sole membership → implied; otherwise null (the client must choose). It also returns active_tenant_header so 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_scope middleware now skips /forge/auth/*. Necessary because /auth/me must return the full membership set, but the middleware refuses multi-membership requests that lack X-Active-Tenant (400 ACTIVE_TENANT_REQUIRED) — i.e. it would reject exactly the users /auth/me exists to serve. This also quietly fixes /auth/refresh, which carries no tenant context of its own, for multi-membership users.

Tests

  • 7 unit: resolve_active_tenant (empty / sole / multi / header-selected / non-member / malformed) + MeResponse serde shape.
  • 4 integration (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-targets clean; full crate suite green (460 passed).

Out of scope / notes

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 rrrodzilla merged commit 32b48db into main May 28, 2026
1 check passed
@rrrodzilla rrrodzilla deleted the feat/auth-me-endpoint branch May 28, 2026 18:45
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.
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: expose /auth/me principal endpoint (user_id, tenant_chain, active_tenant) and /auth/switch-tenant

1 participant