diff --git a/crates/escurel-server/tests/multi_issuer_groups.rs b/crates/escurel-server/tests/multi_issuer_groups.rs new file mode 100644 index 0000000..4f6c65c --- /dev/null +++ b/crates/escurel-server/tests/multi_issuer_groups.rs @@ -0,0 +1,118 @@ +//! The Triton-fronted trust shape, end to end: one gateway that trusts its +//! primary TestIssuer AND a second issuer (`ConfigOverrides::extra_issuers`), +//! reading group memberships from a **custom claim** +//! (`ConfigOverrides::groups_claim = "triton_sender_groups"` — the claim +//! Triton's static-upstream signer mints; escurel's production knob is +//! `ESCUREL_AUTH_GROUPS_CLAIM`). Real gateway, real Indexer, real JWKS over +//! the wire for both issuers; no mocks. +//! +//! Pins three things: +//! 1. a second-issuer token with an **audience array** (`aud: [agents, +//! escurel]`, the multi-audience shape Triton mints) and groups under +//! the custom claim passes verification and gains the group read; +//! 2. the same issuer without the group is fail-closed (hidden, no error); +//! 3. the PRIMARY TestIssuer's minted principals keep their groups when +//! the custom claim is configured (claim-aware minting — without it, +//! setting the knob would silently strip every existing test principal). + +use escurel_test_support::{ + AuthMode, ConfigOverrides, EscurelProcess, ExtraIssuer, FixtureBuilder, Opts, +}; +use serde_json::{Value, json}; + +const TENANT: &str = "acme"; +const GROUPS_CLAIM: &str = "triton_sender_groups"; + +const DEAL_SKILL: &str = "---\ntype: skill\nid: deal\n\ + description: A sales deal, readable by the sales group.\n\ + acl:\n read: [sales]\n\ + ---\n# deal\n"; +const DEAL: &str = "---\ntype: instance\nskill: deal\nid: q3-renewal\n---\n\ + # Q3 renewal\nBeverages GmbH renewal.\n"; +const DEAL_PAGE: &str = "markdown/instances/deal/q3-renewal.md"; + +async fn call(p: &EscurelProcess, token: &str, name: &str, args: Value) -> Value { + let resp = reqwest::Client::new() + .post(p.mcp_url()) + .header("authorization", format!("Bearer {token}")) + .json(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": name, "arguments": args }, + })) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 200, "http status"); + resp.json().await.unwrap() +} + +#[tokio::test] +async fn second_issuer_token_with_custom_groups_claim_gains_group_read() { + let extra = ExtraIssuer::start().await; + let p = EscurelProcess::spawn(Opts { + auth: AuthMode::TestIssuer, + fixtures: Some( + FixtureBuilder::new() + .tenant(TENANT) + .skill("deal", DEAL_SKILL) + .instance("deal", "q3-renewal", DEAL) + .done(), + ), + config_overrides: ConfigOverrides { + groups_claim: Some(GROUPS_CLAIM.to_owned()), + extra_issuers: vec![(extra.issuer_url().to_owned(), extra.jwks_url().to_owned())], + ..Default::default() + }, + }) + .await; + + // 1. Triton-shaped token: second issuer, audience ARRAY naming escurel + // among others, groups under the custom claim → the group read works. + let maria = extra.mint( + TENANT, + "maria", + &["agents-test", "escurel"], + GROUPS_CLAIM, + &["sales"], + ); + let body = call(&p, &maria, "expand", json!({ "page_id": DEAL_PAGE })).await; + assert!(body.get("error").is_none(), "maria verifies: {body}"); + let page = &body["result"]["structuredContent"]; + assert!( + page["body"] + .as_str() + .is_some_and(|b| b.contains("Q3 renewal")), + "sales-group read via the second issuer + custom claim: {page}" + ); + + // 2. Same issuer, no group → fail-closed (page hidden, not an error). + let outsider = extra.mint( + TENANT, + "outsider", + &["agents-test", "escurel"], + GROUPS_CLAIM, + &[], + ); + let body = call(&p, &outsider, "expand", json!({ "page_id": DEAL_PAGE })).await; + assert!(body.get("error").is_none(), "outsider verifies: {body}"); + assert!( + body["result"]["structuredContent"]["page"].is_null() + || body["result"]["structuredContent"]["body"].is_null(), + "no group, no read: {body}" + ); + + // 3. The PRIMARY issuer's mint helpers stay truthful under the knob: + // a TestIssuer principal with the sales group still reads (the + // claim-aware-minting regression). + let analyst = p.mint_token_with_groups(TENANT, "analyst-1", &["sales"], false); + let body = call(&p, &analyst, "expand", json!({ "page_id": DEAL_PAGE })).await; + assert!(body.get("error").is_none(), "analyst verifies: {body}"); + assert!( + body["result"]["structuredContent"]["body"] + .as_str() + .is_some_and(|b| b.contains("Q3 renewal")), + "primary-issuer principal keeps its groups under the custom claim: {body}" + ); + + p.shutdown().await; +} diff --git a/crates/escurel-test-support/src/auth.rs b/crates/escurel-test-support/src/auth.rs index a11f563..510eb42 100644 --- a/crates/escurel-test-support/src/auth.rs +++ b/crates/escurel-test-support/src/auth.rs @@ -116,6 +116,14 @@ pub(crate) struct TestIssuer { pub(crate) keys: Keys, pub(crate) issuer_url: String, pub(crate) jwks_url: String, + /// When the gateway was configured with a custom + /// `ConfigOverrides::groups_claim`, the mint helpers emit the group + /// array under BOTH `roles` (so the `admin_role_claim` projection and + /// every existing caller keep working) and this claim (so the group + /// ACL keeps seeing the groups). Without this, setting the knob would + /// silently strip every TestIssuer principal of its groups — the + /// second-order trap the knob's tests pin. + pub(crate) groups_claim: Option, /// Owned wiremock server. Kept alive so the JWKS endpoint stays /// reachable until the [`crate::EscurelProcess`] is dropped. pub(crate) _mock_server: MockServer, @@ -123,6 +131,10 @@ pub(crate) struct TestIssuer { impl TestIssuer { pub(crate) async fn start() -> Self { + Self::start_with_groups_claim(None).await + } + + pub(crate) async fn start_with_groups_claim(groups_claim: Option) -> Self { let mock_server = MockServer::start().await; let keys = Keys::generate(); mount_jwks(&mock_server, &keys).await; @@ -132,6 +144,7 @@ impl TestIssuer { keys, issuer_url, jwks_url, + groups_claim, _mock_server: mock_server, } } @@ -181,7 +194,7 @@ impl TestIssuer { fn sign_with_roles(&self, tenant: &str, subject: &str, roles: &[String]) -> String { let now = now_secs(); - let claims = json!({ + let mut claims = json!({ "iss": self.issuer_url, "aud": TEST_AUDIENCE, "sub": subject, @@ -190,6 +203,13 @@ impl TestIssuer { "iat": now, "exp": now + 600, }); + // Claim-aware minting: mirror the group array under the configured + // groups claim (see the `groups_claim` field docs). + if let Some(claim) = &self.groups_claim + && claim != "roles" + { + claims[claim.as_str()] = json!(roles); + } let mut header = Header::new(Algorithm::RS256); header.kid = Some(TEST_KID.to_owned()); let key = EncodingKey::from_rsa_pem(&self.keys.private_pem).expect("rsa pem parses"); @@ -197,6 +217,61 @@ impl TestIssuer { } } +/// A standalone in-process OIDC issuer for **multi-issuer** tests — the +/// deployed shape where one Escurel trusts its primary issuer AND a second +/// party's signer (Triton's forwarded bearers, Carl's dashboard tokens). +/// Downstream tests wire it in via `ConfigOverrides::extra_issuers` and +/// mint tokens with arbitrary audience arrays + a custom groups claim, all +/// without importing `wiremock`/`jsonwebtoken` themselves (the dx.md +/// "Auth in tests" promise). +pub struct ExtraIssuer { + inner: TestIssuer, +} + +impl ExtraIssuer { + pub async fn start() -> Self { + Self { + inner: TestIssuer::start().await, + } + } + + /// The issuer URL (the token's `iss`). + pub fn issuer_url(&self) -> &str { + &self.inner.issuer_url + } + + /// The JWKS URL serving this issuer's signing key. + pub fn jwks_url(&self) -> &str { + &self.inner.jwks_url + } + + /// Sign a bearer with an **audience array** (the Triton-minted shape: + /// `aud: [agents, escurel]`) and the group list under `groups_claim`. + pub fn mint( + &self, + tenant: &str, + subject: &str, + audiences: &[&str], + groups_claim: &str, + groups: &[&str], + ) -> String { + let now = now_secs(); + let claims = json!({ + "iss": self.inner.issuer_url, + "aud": audiences, + "sub": subject, + "tenant": tenant, + groups_claim: groups, + "iat": now, + "exp": now + 600, + }); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(TEST_KID.to_owned()); + let key = EncodingKey::from_rsa_pem(&self.inner.keys.private_pem).expect("rsa pem parses"); + encode(&header, &claims, &key).expect("jwt sign") + } +} + async fn mount_jwks(server: &MockServer, k: &Keys) { let jwks = json!({ "keys": [{ diff --git a/crates/escurel-test-support/src/lib.rs b/crates/escurel-test-support/src/lib.rs index 1acf33a..59eb458 100644 --- a/crates/escurel-test-support/src/lib.rs +++ b/crates/escurel-test-support/src/lib.rs @@ -47,7 +47,7 @@ mod fixtures; mod mcp_client; mod process; -pub use auth::{AuthMode, Role}; +pub use auth::{AuthMode, ExtraIssuer, Role}; pub use fixtures::{FixtureBuilder, MarkdownBody, TenantFixture}; pub use mcp_client::{McpError, McpTestClient}; pub use process::{ConfigOverrides, EscurelProcess, Opts}; diff --git a/crates/escurel-test-support/src/process.rs b/crates/escurel-test-support/src/process.rs index f7f0ef2..05f9252 100644 --- a/crates/escurel-test-support/src/process.rs +++ b/crates/escurel-test-support/src/process.rs @@ -95,6 +95,18 @@ pub struct ConfigOverrides { /// `X-Escurel-Webhook-Signature: sha256=`. `None` (default) /// leaves the POST unsigned. pub webhook_secret: Option, + /// JWT claim the verifier reads the subject's group memberships from + /// (production `ESCUREL_AUTH_GROUPS_CLAIM`; e.g. `triton_sender_groups` + /// in the Triton-fronted topology). `None` keeps the default (`roles`). + /// The TestIssuer's mint helpers are claim-aware: they emit the groups + /// under BOTH `roles` and this claim, so admin projection and existing + /// principals keep working when the knob is set. + pub groups_claim: Option, + /// Additional trusted issuers beyond whatever [`crate::AuthMode`] + /// configures, each `(issuer_url, jwks_url)` — the deployed shape where + /// one Escurel also trusts Triton's (or another service's) signer. + /// Requires an auth mode with a verifier (not `Disabled`). + pub extra_issuers: Vec<(String, String)>, } impl std::fmt::Debug for ConfigOverrides { @@ -241,14 +253,22 @@ impl EscurelProcess { // + an RSA keypair and threads them into an // `OidcVerifier`. External points the verifier at the // caller's URLs. Disabled leaves `verifier = None`. - let (verifier, issuer) = match &opts.auth { + // `overrides.groups_claim` + `overrides.extra_issuers` apply on + // top of whatever the mode configures. + assert!( + !(matches!(opts.auth, AuthMode::Disabled) + && (overrides.groups_claim.is_some() || !overrides.extra_issuers.is_empty())), + "ConfigOverrides::groups_claim / extra_issuers require an auth mode with a verifier \ + (AuthMode::Disabled installs none)" + ); + let (cfg, issuer) = match &opts.auth { AuthMode::Disabled => (None, None), AuthMode::TestIssuer => { - let issuer = TestIssuer::start().await; + let issuer = + TestIssuer::start_with_groups_claim(overrides.groups_claim.clone()).await; let cfg = OidcConfig::new(issuer.issuer_url.clone(), TEST_AUDIENCE.to_owned()) .with_jwks_uri(issuer.jwks_url.clone()); - let verifier = Arc::new(OidcVerifier::new(cfg)); - (Some(verifier), Some(issuer)) + (Some(cfg), Some(issuer)) } AuthMode::External { issuer_url, @@ -256,8 +276,7 @@ impl EscurelProcess { } => { let cfg = OidcConfig::new(issuer_url.clone(), TEST_AUDIENCE.to_owned()) .with_jwks_uri(jwks_url.clone()); - let verifier = Arc::new(OidcVerifier::new(cfg)); - (Some(verifier), None) + (Some(cfg), None) } AuthMode::ExternalMulti { issuers } => { let (primary_iss, primary_jwks) = issuers @@ -268,10 +287,18 @@ impl EscurelProcess { for (iss, jwks) in &issuers[1..] { cfg = cfg.with_additional_issuer(iss.clone(), Some(jwks.clone())); } - let verifier = Arc::new(OidcVerifier::new(cfg)); - (Some(verifier), None) + (Some(cfg), None) } }; + let verifier = cfg.map(|mut cfg| { + if let Some(claim) = &overrides.groups_claim { + cfg = cfg.with_groups_claim(claim.clone()); + } + for (iss, jwks) in &overrides.extra_issuers { + cfg = cfg.with_additional_issuer(iss.clone(), Some(jwks.clone())); + } + Arc::new(OidcVerifier::new(cfg)) + }); // 3. Boot the server. Bound port returned in `local_addr` // *before* the join handle starts polling — so `spawn`