diff --git a/crates/schema-forge-acton/src/middleware/tenant_scope.rs b/crates/schema-forge-acton/src/middleware/tenant_scope.rs index f600a4a..b6dabf5 100644 --- a/crates/schema-forge-acton/src/middleware/tenant_scope.rs +++ b/crates/schema-forge-acton/src/middleware/tenant_scope.rs @@ -80,6 +80,16 @@ where B: Send + 'static, Request: Into>, { + // Auth/identity endpoints (`/api/v1/forge/auth/*`) are not tenant-scoped + // entity access. `/auth/me` must return the user's *full* membership set so + // a client can build a tenant switcher; subjecting it to the multi-membership + // `X-Active-Tenant` requirement below would refuse exactly the users it + // exists to serve. Skipping tenancy here also keeps `/auth/refresh` usable + // for multi-membership users (a refresh carries no tenant context of its own). + if request.uri().path().contains("/forge/auth/") { + return next.run(request.into()).await; + } + // Tenancy disabled at the deployment level — nothing to do. let Some(tenant_config) = state.tenant_config.as_ref() else { return next.run(request.into()).await; @@ -242,7 +252,7 @@ fn select_active_tenant<'a, B>( /// Returns `None` for any malformed input. The entity_id half may itself /// contain colons (TypeIDs use `_`, not `:`, but we don't enforce that /// here — only the first `:` is treated as the separator). -fn parse_active_tenant(s: &str) -> Option<(String, String)> { +pub(crate) fn parse_active_tenant(s: &str) -> Option<(String, String)> { let (schema, rest) = s.split_once(':')?; if schema.is_empty() || rest.is_empty() { return None; diff --git a/crates/schema-forge-acton/src/routes/auth.rs b/crates/schema-forge-acton/src/routes/auth.rs index fd14c47..76f92dd 100644 --- a/crates/schema-forge-acton/src/routes/auth.rs +++ b/crates/schema-forge-acton/src/routes/auth.rs @@ -32,6 +32,7 @@ use serde::{Deserialize, Serialize}; use crate::access::{OptionalClaims, PLATFORM_ADMIN_ROLE}; use crate::authz::principal_claims::{PrincipalClaimMappings, PrincipalClaimsError}; use crate::config::SchemaForgeConfig; +use crate::middleware::tenant_scope::{parse_active_tenant, ACTIVE_TENANT_HEADER}; use crate::state::DynAuthStore; /// Default expiry for tokens minted by this endpoint (1 hour). @@ -80,6 +81,77 @@ struct LoginErrorBody { status: u16, } +/// The tenant currently scoping the session, as surfaced by `GET /auth/me`. +/// +/// Shape mirrors the `X-Active-Tenant: :` header the +/// `tenant_scope` middleware consumes, so a client can round-trip it directly: +/// read `active_tenant` here, send it back as the header to keep that scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveTenant { + /// Tenant schema name (e.g. `"Organization"`). + pub tenant_type: String, + /// Tenant entity id (e.g. `"organization_01k..."`). + pub tenant_id: String, +} + +impl ActiveTenant { + fn from_ref(r: &TenantRef) -> Self { + Self { + tenant_type: r.schema.clone(), + tenant_id: r.entity_id.clone(), + } + } +} + +/// Success response body for `GET /auth/me`. +/// +/// The browser cannot read the encrypted PASETO, so this endpoint is the +/// client-side anchor for "signed in as / current org" chrome: it returns the +/// resolved `user_id`, the user's full tenant membership set (`tenant_chain` — +/// each entry is directly usable to build an `X-Active-Tenant` value), and the +/// currently-active tenant. +#[derive(Debug, Serialize)] +pub struct MeResponse { + /// The User entity id (`user_01k...`) — the stable identity the client + /// otherwise can't obtain without an email→id lookup. + pub user_id: String, + /// The login identifier, which is the user's email. + pub email: String, + pub display_name: Option, + pub roles: Vec, + /// The user's full membership set (every tenant they may operate in). + pub tenant_chain: Vec, + /// The tenant scoping this request: the `X-Active-Tenant` header if present + /// and a member, else the sole membership, else `null` (client must choose). + pub active_tenant: Option, + /// The header name a client sends to set/switch the active tenant. Surfaced + /// so clients don't hard-code it. Switching needs no new token (see #67). + pub active_tenant_header: &'static str, + /// Operator-declared principal-claim projections (same values baked into + /// the token at login), for client-side display/branching. + pub principal_claims: serde_json::Map, +} + +/// Resolve the active tenant from the membership set and an optional +/// `X-Active-Tenant` header value, mirroring `tenant_scope`'s selection rule: +/// a valid header that names a member wins; with no header a sole membership is +/// implied; otherwise there is no resolved active tenant (the client chooses). +/// +/// Pure: no I/O, fully unit-testable. +fn resolve_active_tenant(memberships: &[TenantRef], header: Option<&str>) -> Option { + match header { + Some(h) => { + let (schema, id) = parse_active_tenant(h)?; + memberships + .iter() + .find(|m| m.schema == schema && m.entity_id == id) + .map(ActiveTenant::from_ref) + } + None if memberships.len() == 1 => Some(ActiveTenant::from_ref(&memberships[0])), + None => None, + } +} + // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- @@ -279,6 +351,81 @@ pub async fn refresh( (StatusCode::OK, Json(body)).into_response() } +/// `GET /auth/me` — return the authenticated principal as the server resolves +/// it, so a browser client (which cannot decrypt the PASETO) can render +/// "signed in as / current org" chrome and a tenant switcher without an +/// email→user_id round-trip. +/// +/// `active_tenant` is resolved exactly as `tenant_scope` resolves it for entity +/// requests: the `X-Active-Tenant: :` header when present and a +/// member, else the sole membership, else `null`. Switching tenants is done by +/// sending a different `X-Active-Tenant` header on subsequent requests — no new +/// token, consistent with the model shipped in #67. This route is deliberately +/// exempt from the `tenant_scope` middleware so it can return the *full* +/// membership set rather than the active tenant's hierarchy. +pub async fn me( + OptionalClaims(claims): OptionalClaims, + Extension(auth_store): Extension>, + Extension(principal_claims): Extension>, + headers: axum::http::HeaderMap, +) -> Response { + let Some(claims) = claims else { + return unauthorized_response(); + }; + + let username = claims + .username + .clone() + .unwrap_or_else(|| claims.sub.trim_start_matches("user:").to_string()); + + // Re-read live state (roles/active/memberships), same contract as refresh: + // a grant, revocation, or deactivation since login takes effect here. + let user = match auth_store.get_user(&username).await { + Ok(Some(u)) if u.active => u, + Ok(_) => return unauthorized_response(), + Err(e) => return internal_error_response(format!("auth store error: {e}")), + }; + + let entity = match auth_store.get_user_entity(&username).await { + Ok(Some(e)) => e, + Ok(None) => return unauthorized_response(), + Err(e) => return internal_error_response(format!("auth store error: {e}")), + }; + + let memberships = match auth_store.list_tenant_memberships(&username).await { + Ok(m) => m, + Err(e) => return internal_error_response(format!("auth store error: {e}")), + }; + + let principal_claims_map = if principal_claims.has_user_field_sources() { + match principal_claims.project_user_fields(&entity) { + Ok(m) => m.into_iter().collect(), + Err(e) => { + return internal_error_response(format!("principal claim projection failed: {e}")) + } + } + } else { + serde_json::Map::new() + }; + + let active_tenant = resolve_active_tenant( + &memberships, + headers.get(ACTIVE_TENANT_HEADER).and_then(|v| v.to_str().ok()), + ); + + let body = MeResponse { + user_id: entity.id.to_string(), + email: username, + display_name: user.display_name, + roles: user.roles, + tenant_chain: memberships, + active_tenant, + active_tenant_header: ACTIVE_TENANT_HEADER, + principal_claims: principal_claims_map, + }; + (StatusCode::OK, Json(body)).into_response() +} + // --------------------------------------------------------------------------- // Audit emission helpers // --------------------------------------------------------------------------- @@ -505,16 +652,21 @@ fn internal_error_response(message: String) -> Response { // Router helper // --------------------------------------------------------------------------- -/// Build the auth sub-router, containing just `POST /auth/login`. +/// Build the auth sub-router: `POST /auth/login`, `POST /auth/refresh`, and +/// `GET /auth/me`. /// /// Nested alongside the rest of the forge routes (schemas, entities) under -/// `/api/v1/forge/` by `SchemaForgeExtension::versioned_forge_routes`. +/// `/api/v1/forge/` by `SchemaForgeExtension::versioned_forge_routes`. The +/// `tenant_scope` middleware deliberately skips `/auth/*` (see its early-out), +/// so `/auth/me` sees the full membership set and `/auth/refresh` stays usable +/// for multi-membership users. pub fn auth_routes( ) -> axum::Router> { - use axum::routing::post; + use axum::routing::{get, post}; axum::Router::new() .route("/auth/login", post(login)) .route("/auth/refresh", post(refresh)) + .route("/auth/me", get(me)) } // --------------------------------------------------------------------------- @@ -661,4 +813,90 @@ mod tests { let res = enforce_tenant_membership_policy(&memberships, &[], None); assert!(res.is_ok()); } + + // ----------------------------------------------------------------------- + // /auth/me active-tenant resolution (issue #70) + // ----------------------------------------------------------------------- + + fn tref(schema: &str, id: &str) -> TenantRef { + TenantRef { + schema: schema.to_string(), + entity_id: id.to_string(), + } + } + + #[test] + fn resolve_active_tenant_no_memberships_is_none() { + assert_eq!(resolve_active_tenant(&[], None), None); + assert_eq!(resolve_active_tenant(&[], Some("Organization:org-a")), None); + } + + #[test] + fn resolve_active_tenant_sole_membership_without_header() { + let m = vec![tref("Organization", "org-a")]; + assert_eq!( + resolve_active_tenant(&m, None), + Some(ActiveTenant { + tenant_type: "Organization".into(), + tenant_id: "org-a".into(), + }) + ); + } + + #[test] + fn resolve_active_tenant_multi_membership_without_header_is_none() { + let m = vec![tref("Organization", "org-a"), tref("Organization", "org-b")]; + assert_eq!(resolve_active_tenant(&m, None), None); + } + + #[test] + fn resolve_active_tenant_header_selects_member() { + let m = vec![tref("Organization", "org-a"), tref("Organization", "org-b")]; + assert_eq!( + resolve_active_tenant(&m, Some("Organization:org-b")), + Some(ActiveTenant { + tenant_type: "Organization".into(), + tenant_id: "org-b".into(), + }) + ); + } + + #[test] + fn resolve_active_tenant_header_not_a_member_is_none() { + let m = vec![tref("Organization", "org-a")]; + assert_eq!(resolve_active_tenant(&m, Some("Organization:org-x")), None); + } + + #[test] + fn resolve_active_tenant_malformed_header_is_none() { + let m = vec![tref("Organization", "org-a"), tref("Organization", "org-b")]; + assert_eq!(resolve_active_tenant(&m, Some("garbage")), None); + } + + #[test] + fn me_response_serializes_with_expected_shape() { + let body = MeResponse { + user_id: "user_01k".into(), + email: "roland@govcraft.ai".into(), + display_name: Some("Roland".into()), + roles: vec!["admin".into()], + tenant_chain: vec![tref("Organization", "org-a")], + active_tenant: Some(ActiveTenant { + tenant_type: "Organization".into(), + tenant_id: "org-a".into(), + }), + active_tenant_header: ACTIVE_TENANT_HEADER, + principal_claims: serde_json::Map::new(), + }; + let v = serde_json::to_value(&body).unwrap(); + assert_eq!(v["user_id"], "user_01k"); + assert_eq!(v["email"], "roland@govcraft.ai"); + assert_eq!(v["tenant_chain"][0]["schema"], "Organization"); + assert_eq!(v["active_tenant"]["tenant_type"], "Organization"); + assert_eq!(v["active_tenant"]["tenant_id"], "org-a"); + assert_eq!(v["active_tenant_header"], "x-active-tenant"); + // active_tenant is null (not omitted) when there's no resolved tenant. + let none_body = serde_json::json!({ "active_tenant": Option::::None }); + assert!(none_body["active_tenant"].is_null()); + } } diff --git a/crates/schema-forge-acton/tests/auth_login.rs b/crates/schema-forge-acton/tests/auth_login.rs index 3184a88..72b6b86 100644 --- a/crates/schema-forge-acton/tests/auth_login.rs +++ b/crates/schema-forge-acton/tests/auth_login.rs @@ -443,3 +443,114 @@ async fn login_failure_leaves_last_login_untouched() { "last_login must remain unset after a failed login" ); } + +// --------------------------------------------------------------------------- +// GET /auth/me (issue #70) +// --------------------------------------------------------------------------- + +/// Build a `Claims` envelope the way the token middleware would inject one, +/// so `/auth/me` can be driven without standing up the full middleware stack. +fn claims_for(username: &str, roles: &[&str]) -> acton_service::middleware::Claims { + use acton_service::auth::tokens::ClaimsBuilder; + let mut b = ClaimsBuilder::new() + .user(username) + .username(username) + .issuer("schemaforge"); + for r in roles { + b = b.role(*r); + } + b.build().expect("build claims") +} + +/// GET `/auth/me` with an optional injected `Claims` extension and an optional +/// `X-Active-Tenant` header. +async fn get_me( + app: Router, + claims: Option, + active_tenant: Option<&str>, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(Method::GET).uri("/auth/me"); + if let Some(h) = active_tenant { + builder = builder.header("x-active-tenant", h); + } + let mut req = builder.body(Body::empty()).unwrap(); + if let Some(c) = claims { + req.extensions_mut().insert(c); + } + let res = app.oneshot(req).await.unwrap(); + let status = res.status(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let body = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("response body is JSON") + }; + (status, body) +} + +#[tokio::test] +async fn me_returns_principal_and_full_membership_set() { + let (store, _cfg) = seeded_auth_store_with_memberships(2, &["member"]).await; + let (app, _key, _store) = login_app_with(store, None).await; + + let (status, body) = get_me(app, Some(claims_for("alice", &["member"])), None).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["email"], "alice"); + assert!( + body["user_id"].as_str().unwrap().starts_with("user_"), + "user_id should be the User entity id, got {:?}", + body["user_id"] + ); + let chain = body["tenant_chain"].as_array().expect("tenant_chain array"); + assert_eq!(chain.len(), 2, "alice has two memberships"); + // Multiple memberships and no X-Active-Tenant header => no resolved active + // tenant; the client must choose. (The header model shipped in #67.) + assert!(body["active_tenant"].is_null()); + assert_eq!(body["active_tenant_header"], "x-active-tenant"); +} + +#[tokio::test] +async fn me_resolves_active_tenant_from_header() { + let (store, _cfg) = seeded_auth_store_with_memberships(2, &["member"]).await; + let (app, _key, _store) = login_app_with(store, None).await; + + let (status, body) = get_me( + app, + Some(claims_for("alice", &["member"])), + Some("Organization:org-b"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["active_tenant"]["tenant_type"], "Organization"); + assert_eq!(body["active_tenant"]["tenant_id"], "org-b"); +} + +#[tokio::test] +async fn me_with_non_member_active_tenant_header_resolves_null() { + let (store, _cfg) = seeded_auth_store_with_memberships(2, &["member"]).await; + let (app, _key, _store) = login_app_with(store, None).await; + + let (status, body) = get_me( + app, + Some(claims_for("alice", &["member"])), + Some("Organization:org-not-mine"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert!( + body["active_tenant"].is_null(), + "a header naming a non-member tenant must not resolve" + ); +} + +#[tokio::test] +async fn me_without_claims_returns_401() { + let (store, _cfg) = seeded_auth_store_with_memberships(1, &["member"]).await; + let (app, _key, _store) = login_app_with(store, None).await; + + let (status, _body) = get_me(app, None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} diff --git a/docs/principal-claims-reference.md b/docs/principal-claims-reference.md index caea08a..1c735c4 100644 --- a/docs/principal-claims-reference.md +++ b/docs/principal-claims-reference.md @@ -482,6 +482,12 @@ Header rules: - **Header references a tenant the user is not a member of**: 403 `ACTIVE_TENANT_FORBIDDEN`. Closes impersonation. +There is **no separate "switch tenant" endpoint and no active-tenant token +claim**. Switching is purely a client concern: send a different +`X-Active-Tenant` header on the next request — no re-login, no new token. A +browser discovers its memberships (to build a switcher) and its currently +resolved active tenant via `GET /auth/me`; see §10.7. + ### 10.3 Zero-membership policy If `@tenant` annotations are present in the deployment's schemas @@ -559,3 +565,55 @@ curl -sf -H "authorization: Bearer $BOB_TOKEN" \ http://localhost:3000/api/v1/forge/schemas/Opportunity/entities # 403 ACTIVE_TENANT_FORBIDDEN — bob isn't a member of org-c. ``` + +### 10.7 Client read of the contract: `GET /auth/me` + +A browser client cannot decode the PASETO (it is `v4.local`, encrypted with +the server's symmetric key), so it cannot see its own `user_id`, its +memberships, or which tenant is active. `GET /api/v1/forge/auth/me` is the +authenticated read side of this contract — the anchor for "signed in as / +current org" chrome and a tenant switcher: + +```jsonc +{ + "user_id": "user_01k...", // the User entity id (no email→id lookup) + "email": "alice@agency.gov", // the login identifier + "display_name": "Alice Stone", + "roles": ["member"], + "tenant_chain": [ // the FULL membership set (§10.1), + { "schema": "Organization", "entity_id": "org-a" }, // each entry is + { "schema": "Organization", "entity_id": "org-b" } // directly usable as + ], // an X-Active-Tenant value + "active_tenant": { "tenant_type": "Organization", "tenant_id": "org-a" }, + "active_tenant_header": "X-Active-Tenant", + "principal_claims": { /* projected user-field claims, §1 */ } +} +``` + +Key behaviors: + +- **`active_tenant` resolution mirrors §10.2.** If the request carries a valid + `X-Active-Tenant` header naming a member, that tenant is reported; with a sole + membership it is implied; otherwise (multi-membership, no header, or a header + naming a non-member) it is `null` — the client must choose. `active_tenant_header` + names the header to send, so clients don't hard-code it. +- **`/auth/me` is exempt from the `tenant_scope` middleware.** It must return the + *full* membership set so a client can render every switchable tenant; the + multi-membership `ACTIVE_TENANT_REQUIRED` rule (§10.2) would otherwise reject + exactly the multi-tenant users this endpoint serves. (`/auth/refresh` is exempt + for the same reason — it carries no tenant context of its own.) +- **Switching uses §10.2, not a new token.** Read the options here, then send the + chosen `X-Active-Tenant` header on subsequent entity requests. + +```sh +# bob (multi-membership): discover identity + switchable tenants. +curl -sf -H "authorization: Bearer $BOB_TOKEN" \ + http://localhost:3000/api/v1/forge/auth/me +# active_tenant: null (multi-membership, no header → client must choose) + +# confirm which tenant a given header resolves to before using it for writes. +curl -sf -H "authorization: Bearer $BOB_TOKEN" \ + -H "X-Active-Tenant: Organization:org-b" \ + http://localhost:3000/api/v1/forge/auth/me +# active_tenant: { tenant_type: "Organization", tenant_id: "org-b" } +```