diff --git a/crates/jerrycan-auth/src/guard.rs b/crates/jerrycan-auth/src/guard.rs index a93f72c..3cca3e8 100644 --- a/crates/jerrycan-auth/src/guard.rs +++ b/crates/jerrycan-auth/src/guard.rs @@ -47,6 +47,18 @@ pub fn require_role(actual: &str, required: &str) -> Result<()> { } } +/// Any-of role check for generated guards: `403` unless `actual` is one of +/// `allowed` (the multi-role form of [`require_role`], emitted when an endpoint +/// declares more than one `required_roles`). Mirrors `Tenant::require_any_role` +/// on the path-scoped side. +pub fn require_any_role(actual: &str, allowed: &[&str]) -> Result<()> { + if allowed.contains(&actual) { + Ok(()) + } else { + Err(Error::forbidden()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -106,6 +118,14 @@ mod tests { assert_eq!(res.json::(), "1"); } + #[test] + fn require_any_role_accepts_a_role_in_the_set_and_403s_the_rest() { + assert!(require_any_role("admin", &["admin", "editor"]).is_ok()); + assert!(require_any_role("editor", &["admin", "editor"]).is_ok()); + let err = require_any_role("viewer", &["admin", "editor"]).unwrap_err(); + assert_eq!(err.code(), "JC0403"); + } + #[tokio::test] async fn require_role_rejects_wrong_role_with_403() { async fn admin_only(Session(user): Session) -> Result<&'static str> { diff --git a/crates/jerrycan-auth/src/lib.rs b/crates/jerrycan-auth/src/lib.rs index 41c61b4..12c9a4c 100644 --- a/crates/jerrycan-auth/src/lib.rs +++ b/crates/jerrycan-auth/src/lib.rs @@ -33,7 +33,7 @@ pub use api_key::{ ApiKey, ApiKeyFuture, ApiKeyRecord, ApiKeyStore, ApiKeys, InMemoryApiKeyStore, MintedApiKey, hash_key, mint, require_scope, verify, }; -pub use guard::{Bearer, Session, require_role}; +pub use guard::{Bearer, Session, require_any_role, require_role}; #[cfg(feature = "idtoken")] pub use idtoken::{HttpJwksSource, IdTokenClaims, Jwk, Jwks, JwksFuture, JwksSource, Verifier}; #[cfg(any(all(test, feature = "oauth"), feature = "mock-idp"))] diff --git a/crates/jerrycan/src/platform/genroute.rs b/crates/jerrycan/src/platform/genroute.rs index f65d0ab..528cb11 100644 --- a/crates/jerrycan/src/platform/genroute.rs +++ b/crates/jerrycan/src/platform/genroute.rs @@ -550,10 +550,23 @@ fn guard_comment(m: &ModuleDesign, ep: &Endpoint, design: &Design) -> String { return String::new(); } let roles = ep.required_roles.join("\", \""); + // Multi-role (`required_roles.len() > 1`) needs the ANY-OF primitive: the + // single-role calls (`require_role`/`Tenant::require_role`) take ONE role, so + // interpolating a joined list produces an uncompilable 2-arg call that an agent + // either can't build or "fixes" by dropping every role but the first — silently + // narrowing the authorization set. Emit `require_any_role(&[...])` instead. A + // single role keeps the original guidance BYTE-IDENTICAL (the join is a no-op). + let multi = ep.required_roles.len() > 1; if endpoint_uses_tenant_guard(m, ep, design) { - format!( - " // guard: requires role \"{roles}\" — call _tenant.require_role(\"{roles}\")? before proceeding\n" - ) + if multi { + format!( + " // guard: requires one of roles \"{roles}\" — call _tenant.require_any_role(&[\"{roles}\"])? before proceeding\n" + ) + } else { + format!( + " // guard: requires role \"{roles}\" — call _tenant.require_role(\"{roles}\")? before proceeding\n" + ) + } } else if matches!( design.endpoint_tenant_shape(m, ep), TenantShape::MembershipSet @@ -574,6 +587,10 @@ fn guard_comment(m: &ModuleDesign, ep: &Endpoint, design: &Design) -> String { " // guard: requires MEMBERSHIP role \"{roles}\" (issue #247) — this flat create has\n // no row yet, so check the caller's role in the BODY's tenant via `{{tenant}}_members`,\n // NOT the session role `_user.0.role` (a tenant owner's session role may differ).\n" ) } + } else if multi { + format!( + " // guard: requires one of roles \"{roles}\" — add `use jerrycan::auth::require_any_role;` and call require_any_role(&_user.0.role, &[\"{roles}\"])? before proceeding\n" + ) } else { format!( " // guard: requires role \"{roles}\" — add `use jerrycan::auth::require_role;` and call require_role(&_user.0.role, \"{roles}\")? before proceeding\n" @@ -5574,6 +5591,77 @@ pub(crate) mod tests { ); } + /// T1: a multi-role `required_roles` must emit a COMPILABLE any-of guard. + /// The single-role calls take ONE role, so a joined 2-role list produced an + /// uncompilable 2-arg `require_role(..)` call — an agent then drops all but the + /// first role, silently narrowing authorization. A `len() > 1` endpoint emits + /// `require_any_role`; a `len() == 1` endpoint stays byte-identical. + #[test] + fn multi_role_guard_comment_uses_require_any_role_single_is_unchanged() { + // (a) Session-role gate (Collection/None route). Multi-role → free + // `require_any_role`; single-role byte-identical to the pre-fix guidance. + let d: Design = serde_json::from_str( + r#"{ + "name": "admin-api", "contract_version": 1, + "auth": { "model": "session", "roles": ["admin", "editor", "viewer"] }, + "dependencies": ["auth"], + "modules": [{ "name": "posts", + "entities": [{ "name": "Post", "fields": [ + { "name": "id", "type": "integer" }, { "name": "title", "type": "string" } ]}], + "endpoints": [ + { "operation_id": "multi", "method": "POST", "path": "/", "auth_required": true, + "required_roles": ["admin", "editor"], + "success": { "status": 201, "entity": "Post" } }, + { "operation_id": "single", "method": "DELETE", "path": "/{id}", "auth_required": true, + "required_roles": ["admin"], "success": { "status": 204 } } ] }] + }"#, + ) + .unwrap(); + let m = &d.modules[0]; + let multi = guard_comment(m, &m.endpoints[0], &d); + assert!( + multi.contains("require_any_role(&_user.0.role, &[\"admin\", \"editor\"])?") + && multi.contains("use jerrycan::auth::require_any_role;"), + "multi-role session gate must steer to the any-of primitive: {multi}" + ); + assert!( + !multi.contains("require_role(&_user.0.role"), + "multi-role must NOT emit the single-role call (drops all but the first): {multi}" + ); + assert_eq!( + guard_comment(m, &m.endpoints[1], &d), + " // guard: requires role \"admin\" — add `use jerrycan::auth::require_role;` and call require_role(&_user.0.role, \"admin\")? before proceeding\n", + "single-role session guidance must be byte-identical to the pre-fix output" + ); + + // (b) Path-scoped tenant gate (`Dep`). Multi → `_tenant.require_any_role`; + // single → `_tenant.require_role`, byte-identical. + let ts: Design = serde_json::from_str(ORG_ACCOUNT_CONTACT_UNDER_TENANT).unwrap(); + let sc = &ts.modules[2]; + assert!( + matches!( + ts.endpoint_tenant_shape(sc, &sc.endpoints[0]), + TenantShape::PathScoped { .. } + ), + "fixture precondition: contacts endpoint is path-scoped" + ); + let mut ep_multi = sc.endpoints[0].clone(); + ep_multi.required_roles = vec!["owner".to_string(), "member".to_string()]; + let g_multi = guard_comment(sc, &ep_multi, &ts); + assert!( + g_multi.contains("_tenant.require_any_role(&[\"owner\", \"member\"])?") + && !g_multi.contains("_tenant.require_role("), + "multi-role path-scoped gate must steer to _tenant.require_any_role: {g_multi}" + ); + let mut ep_single = sc.endpoints[0].clone(); + ep_single.required_roles = vec!["owner".to_string()]; + assert_eq!( + guard_comment(sc, &ep_single, &ts), + " // guard: requires role \"owner\" — call _tenant.require_role(\"owner\")? before proceeding\n", + "single-role path-scoped guidance must be byte-identical to the pre-fix output" + ); + } + /// Issue #79 — MAKE THE PER-USER LEAK IMPOSSIBLE. A guarded entity that /// belongs_to the auth identity (Collection/Bookmark → `user_id`), in a /// non-tenancy auth design, gets ONLY the owner-scoped `*_for(user_id)`