Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions crates/escurel-server/tests/multi_issuer_groups.rs
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 76 additions & 1 deletion crates/escurel-test-support/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,25 @@ 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<String>,
/// Owned wiremock server. Kept alive so the JWKS endpoint stays
/// reachable until the [`crate::EscurelProcess`] is dropped.
pub(crate) _mock_server: MockServer,
}

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<String>) -> Self {
let mock_server = MockServer::start().await;
let keys = Keys::generate();
mount_jwks(&mock_server, &keys).await;
Expand All @@ -132,6 +144,7 @@ impl TestIssuer {
keys,
issuer_url,
jwks_url,
groups_claim,
_mock_server: mock_server,
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -190,13 +203,75 @@ 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");
encode(&header, &claims, &key).expect("jwt sign")
}
}

/// 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": [{
Expand Down
2 changes: 1 addition & 1 deletion crates/escurel-test-support/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
43 changes: 35 additions & 8 deletions crates/escurel-test-support/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ pub struct ConfigOverrides {
/// `X-Escurel-Webhook-Signature: sha256=<hex>`. `None` (default)
/// leaves the POST unsigned.
pub webhook_secret: Option<String>,
/// 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<String>,
/// 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 {
Expand Down Expand Up @@ -241,23 +253,30 @@ 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,
jwks_url,
} => {
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
Expand All @@ -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`
Expand Down
Loading