From bd5e96b89a862a1712569391c6f1daeece44fcb8 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:42:08 -0400 Subject: [PATCH 01/11] feat(authz): ES256 JWT verification core for platform-issued tokens (ARN-255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel gains a self-contained ES256 (ECDSA P-256) JWT verifier: the security core of RFC-0002 step 1, letting the platform verify tokens minted by an allowlisted issuer instead of trusting self-declared identity headers. - RustCrypto p256 (no ring); ES256-only, alg=none/HS*/RS* rejected before any key is consulted (alg-confusion safe). - Time (exp/nbf) validated against a caller-supplied now_unix from sim_now(), never a wall clock — DST-deterministic, no JWT library clock involved. - 16 adversarial unit tests: valid verify, alg=none, alg confusion, wrong key, tampered payload, expired, not-yet-valid, leeway, wrong iss/aud, aud array, unknown/ambiguous kid, malformed shapes. Wiring into the resolver + the TrustedIssuer registry follow in this branch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- Cargo.lock | 120 ++++++ crates/temper-server/Cargo.toml | 5 + crates/temper-server/src/identity/jwt.rs | 492 +++++++++++++++++++++++ crates/temper-server/src/identity/mod.rs | 1 + 4 files changed, 618 insertions(+) create mode 100644 crates/temper-server/src/identity/jwt.rs diff --git a/Cargo.lock b/Cargo.lock index 5b9d7ed73..15784ad27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -1467,6 +1473,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1825,6 +1843,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecommerce-reference" version = "0.1.0" @@ -1889,6 +1921,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2049,6 +2101,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2330,6 +2392,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2439,6 +2502,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -4237,6 +4311,18 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -4629,6 +4715,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -5207,6 +5302,16 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5542,6 +5647,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -6684,6 +6803,7 @@ dependencies = [ "lru", "opentelemetry", "opentelemetry_sdk", + "p256", "pprof", "reqwest", "serde", diff --git a/crates/temper-server/Cargo.toml b/crates/temper-server/Cargo.toml index 6ce2378df..ba8caf720 100644 --- a/crates/temper-server/Cargo.toml +++ b/crates/temper-server/Cargo.toml @@ -51,6 +51,11 @@ cedar-policy = { workspace = true } async-trait = { workspace = true } tokio-tungstenite = { version = "0.27", features = ["native-tls"] } ed25519-dalek = "2.1" +# ES256 (ECDSA P-256) verification for platform-issued JWTs (ARN-255). +# RustCrypto (pure Rust, no ring) to match existing sha2/aes-gcm/dalek usage; +# time validation is done against sim_now(), never the wall clock, so no +# JWT library's internal clock is involved. +p256 = { version = "0.13", features = ["ecdsa"] } futures-util = "0.3" sha1 = "0.10" base64 = "0.22" diff --git a/crates/temper-server/src/identity/jwt.rs b/crates/temper-server/src/identity/jwt.rs new file mode 100644 index 000000000..f83d99d7b --- /dev/null +++ b/crates/temper-server/src/identity/jwt.rs @@ -0,0 +1,492 @@ +//! ES256 (ECDSA P-256) JWT verification for platform-issued access tokens. +//! +//! The platform authorization server (and, during the ARN-255 rollout, +//! katagami.ai's authorization server as the first allowlisted issuer) mints +//! short-lived ES256 JWTs. This module verifies one against a registered +//! issuer's JWKS and returns the validated claims. See RFC-0002. +//! +//! Design constraints: +//! - **ES256 only.** The header `alg` must be exactly `ES256`; every other +//! value — including `none`, `HS256`, and `RS256` — is rejected before any +//! key is consulted. The verification path only ever performs P-256 ECDSA, +//! so an algorithm-substitution ("alg confusion") attack cannot select a +//! different primitive. +//! - **Time is caller-supplied.** `exp`/`nbf` are validated against a +//! `now_unix` the caller derives from `sim_now()`, never a wall clock, so +//! verification is deterministic under DST. No JWT library's internal clock +//! is involved. +//! - **The signature is the gate.** The unverified `iss` claim is only used by +//! the caller to select which registered issuer's keys to check against; a +//! forged token fails the signature check because it lacks the issuer's +//! private key. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::signature::Verifier; +use p256::ecdsa::{Signature, VerifyingKey}; +use serde::Deserialize; + +/// The only JWS algorithm this verifier accepts. +const REQUIRED_ALG: &str = "ES256"; + +/// Reasons a token is rejected. Kept coarse on purpose: callers log the +/// variant internally but must not leak which step failed back to the client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JwtError { + /// Not three non-empty base64url segments, or a segment failed to decode. + Malformed, + /// Header `alg` was not exactly `ES256` (covers `none`, HS*, RS*, PS*, …). + UnsupportedAlg, + /// No JWK in the issuer's set matched the token's `kid`. + UnknownKid, + /// A matched JWK could not be turned into a P-256 verifying key. + BadKey, + /// The signature did not verify against the selected key. + BadSignature, + /// `exp` is missing, or `now > exp + leeway`. + Expired, + /// `nbf` is present and `now < nbf - leeway`. + NotYetValid, + /// `iss` did not equal the expected issuer. + WrongIssuer, + /// `aud` did not contain the expected audience. + WrongAudience, +} + +/// A single JSON Web Key (the subset this verifier needs). +#[derive(Debug, Clone, Deserialize)] +pub struct Jwk { + /// Key type; must be `EC`. + pub kty: String, + /// Curve; must be `P-256`. + #[serde(default)] + pub crv: String, + /// Key ID, matched against the token header's `kid`. + #[serde(default)] + pub kid: Option, + /// Base64url X coordinate (32 bytes once decoded). + #[serde(default)] + pub x: String, + /// Base64url Y coordinate (32 bytes once decoded). + #[serde(default)] + pub y: String, +} + +/// A JWKS document — a set of JWKs. +#[derive(Debug, Clone, Deserialize)] +pub struct Jwks { + pub keys: Vec, +} + +/// JWT header (the fields this verifier reads). +#[derive(Debug, Deserialize)] +struct Header { + alg: String, + #[serde(default)] + kid: Option, +} + +/// `aud` may be a single string or an array of strings per RFC 7519. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +enum Audience { + One(String), + Many(Vec), +} + +impl Audience { + fn contains(&self, expected: &str) -> bool { + match self { + Audience::One(a) => a == expected, + Audience::Many(list) => list.iter().any(|a| a == expected), + } + } +} + +/// The validated claim set returned on success. +/// +/// Fields beyond the registered/standard set are ignored. `sub` is the owning +/// human; `client_id` is the acting agent; `agent_type` drives Cedar; the +/// remaining optional fields are carried for downstream use (grant liveness, +/// sign-out-everywhere) without being validated here. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Claims { + pub iss: String, + #[serde(default)] + pub sub: Option, + aud: Audience, + exp: i64, + #[serde(default)] + nbf: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub agent_type: Option, + #[serde(default)] + pub grant_id: Option, + #[serde(default)] + pub auth_generation: Option, + #[serde(default)] + pub scope: Option, +} + +/// Decode the claim segment WITHOUT verifying the signature. +/// +/// The only legitimate use is reading `iss` to select which registered issuer +/// to verify against; the returned claims are untrusted until [`verify`] has +/// run against that issuer's keys. +pub fn decode_claims_unverified(token: &str) -> Result { + let mut parts = token.split('.'); + let (_h, payload, _s) = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() => { + (h, p, s) + } + _ => return Err(JwtError::Malformed), + }; + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| JwtError::Malformed)?; + serde_json::from_slice(&payload_bytes).map_err(|_| JwtError::Malformed) +} + +/// Verify an ES256 JWT against a registered issuer's key set and validate its +/// standard claims. +/// +/// - `now_unix` MUST come from `sim_now().timestamp()`. +/// - `leeway_secs` absorbs small clock skew between issuer and kernel in +/// production; under DST it is deterministic like everything else. +/// +/// On success the signature verified, `alg == ES256`, `iss`/`aud` matched, and +/// the token is within its `nbf`/`exp` window. +pub fn verify( + token: &str, + jwks: &Jwks, + expected_iss: &str, + expected_aud: &str, + now_unix: i64, + leeway_secs: i64, +) -> Result { + // 1. Split into exactly three non-empty segments. + let mut parts = token.split('.'); + let (header_b64, payload_b64, sig_b64) = + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s), None) + if !h.is_empty() && !p.is_empty() && !s.is_empty() => + { + (h, p, s) + } + _ => return Err(JwtError::Malformed), + }; + + // 2. Header: require alg == ES256 before touching any key material. + let header_bytes = URL_SAFE_NO_PAD + .decode(header_b64) + .map_err(|_| JwtError::Malformed)?; + let header: Header = serde_json::from_slice(&header_bytes).map_err(|_| JwtError::Malformed)?; + if header.alg != REQUIRED_ALG { + return Err(JwtError::UnsupportedAlg); + } + + // 3. Select the key by kid. If the token names a kid, it must match; if it + // omits one, a single-key set is unambiguous, otherwise reject. + let jwk = select_key(jwks, header.kid.as_deref())?; + let verifying_key = verifying_key_from_jwk(jwk)?; + + // 4. Verify the signature over "header.payload" (raw r||s, 64 bytes). + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| JwtError::Malformed)?; + let signature = Signature::from_slice(&sig_bytes).map_err(|_| JwtError::BadSignature)?; + let signing_input = format!("{header_b64}.{payload_b64}"); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| JwtError::BadSignature)?; + + // 5. Signature is valid — now parse and validate claims. + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| JwtError::Malformed)?; + let claims: Claims = serde_json::from_slice(&payload_bytes).map_err(|_| JwtError::Malformed)?; + + if claims.iss != expected_iss { + return Err(JwtError::WrongIssuer); + } + if !claims.aud.contains(expected_aud) { + return Err(JwtError::WrongAudience); + } + if now_unix > claims.exp + leeway_secs { + return Err(JwtError::Expired); + } + if let Some(nbf) = claims.nbf { + if now_unix < nbf - leeway_secs { + return Err(JwtError::NotYetValid); + } + } + + Ok(claims) +} + +/// Pick the JWK to verify against, honoring the token's `kid`. +fn select_key<'a>(jwks: &'a Jwks, kid: Option<&str>) -> Result<&'a Jwk, JwtError> { + match kid { + Some(k) => jwks + .keys + .iter() + .find(|j| j.kid.as_deref() == Some(k)) + .ok_or(JwtError::UnknownKid), + // No kid in the header: only unambiguous if the set has exactly one key. + None => match jwks.keys.as_slice() { + [single] => Ok(single), + _ => Err(JwtError::UnknownKid), + }, + } +} + +/// Build a P-256 verifying key from a JWK's affine coordinates. +fn verifying_key_from_jwk(jwk: &Jwk) -> Result { + if jwk.kty != "EC" || jwk.crv != "P-256" { + return Err(JwtError::BadKey); + } + let x = URL_SAFE_NO_PAD.decode(&jwk.x).map_err(|_| JwtError::BadKey)?; + let y = URL_SAFE_NO_PAD.decode(&jwk.y).map_err(|_| JwtError::BadKey)?; + if x.len() != 32 || y.len() != 32 { + return Err(JwtError::BadKey); + } + let point = p256::EncodedPoint::from_affine_coordinates( + p256::FieldBytes::from_slice(&x), + p256::FieldBytes::from_slice(&y), + false, + ); + let key = VerifyingKey::from_encoded_point(&point).map_err(|_| JwtError::BadKey)?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::Signer; + use p256::ecdsa::{Signature as EcdsaSig, SigningKey}; + + // A fixed, deterministic test keypair (DST-safe — no randomness at test time). + fn test_key() -> SigningKey { + // 32-byte scalar; fixed so tests are reproducible. + let bytes = [7u8; 32]; + SigningKey::from_slice(&bytes).expect("valid scalar") + } + + fn jwks_for(sk: &SigningKey, kid: &str) -> Jwks { + let vk = sk.verifying_key(); + let point = vk.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + Jwks { + keys: vec![Jwk { + kty: "EC".into(), + crv: "P-256".into(), + kid: Some(kid.into()), + x, + y, + }], + } + } + + fn b64(v: &serde_json::Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap()) + } + + /// Mint a signed ES256 token from header+claims JSON. + fn mint(sk: &SigningKey, header: serde_json::Value, claims: serde_json::Value) -> String { + let signing_input = format!("{}.{}", b64(&header), b64(&claims)); + let sig: EcdsaSig = sk.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes()); + format!("{signing_input}.{sig_b64}") + } + + fn valid_header() -> serde_json::Value { + serde_json::json!({ "alg": "ES256", "kid": "k1", "typ": "JWT" }) + } + + fn valid_claims() -> serde_json::Value { + serde_json::json!({ + "iss": "https://katagami.ai", + "sub": "human-sub-123", + "aud": "temper", + "exp": 2000, + "nbf": 1000, + "client_id": "agent-xyz", + "agent_type": "contributor", + }) + } + + fn verify_valid(token: &str, jwks: &Jwks) -> Result { + verify(token, jwks, "https://katagami.ai", "temper", 1500, 60) + } + + #[test] + fn valid_token_verifies_and_maps_claims() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let claims = verify_valid(&token, &jwks).expect("should verify"); + assert_eq!(claims.iss, "https://katagami.ai"); + assert_eq!(claims.sub.as_deref(), Some("human-sub-123")); + assert_eq!(claims.client_id.as_deref(), Some("agent-xyz")); + assert_eq!(claims.agent_type.as_deref(), Some("contributor")); + } + + #[test] + fn alg_none_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + // alg=none, empty signature — the classic bypass. + let header = serde_json::json!({ "alg": "none", "kid": "k1" }); + let signing_input = format!("{}.{}", b64(&header), b64(&valid_claims())); + let token = format!("{signing_input}."); + // Empty third segment → Malformed before alg is even read. + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::Malformed)); + } + + #[test] + fn alg_none_with_nonempty_sig_is_unsupported() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "none", "kid": "k1" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnsupportedAlg)); + } + + #[test] + fn alg_confusion_hs256_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "HS256", "kid": "k1" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnsupportedAlg)); + } + + #[test] + fn wrong_key_is_rejected() { + let sk = test_key(); + // Verify against a different key than the one that signed. + let other = SigningKey::from_slice(&[9u8; 32]).unwrap(); + let jwks = jwks_for(&other, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::BadSignature)); + } + + #[test] + fn tampered_payload_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // Swap the payload for one granting a different agent_type, keep the sig. + let mut parts: Vec<&str> = token.split('.').collect(); + let forged = b64(&serde_json::json!({ + "iss": "https://katagami.ai", "aud": "temper", "exp": 2000, + "agent_type": "owner", + })); + parts[1] = &forged; + let token = parts.join("."); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::BadSignature)); + } + + #[test] + fn expired_token_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 3000, exp = 2000, leeway 60 → expired. + let r = verify(&token, &jwks, "https://katagami.ai", "temper", 3000, 60); + assert_eq!(r, Err(JwtError::Expired)); + } + + #[test] + fn not_yet_valid_token_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 500, nbf = 1000, leeway 60 → not yet valid. + let r = verify(&token, &jwks, "https://katagami.ai", "temper", 500, 60); + assert_eq!(r, Err(JwtError::NotYetValid)); + } + + #[test] + fn leeway_admits_small_skew() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + // now = 2030, exp = 2000, leeway 60 → still valid within skew. + assert!(verify(&token, &jwks, "https://katagami.ai", "temper", 2030, 60).is_ok()); + } + + #[test] + fn wrong_issuer_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let r = verify(&token, &jwks, "https://evil.example", "temper", 1500, 60); + assert_eq!(r, Err(JwtError::WrongIssuer)); + } + + #[test] + fn wrong_audience_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let token = mint(&sk, valid_header(), valid_claims()); + let r = verify(&token, &jwks, "https://katagami.ai", "other-app", 1500, 60); + assert_eq!(r, Err(JwtError::WrongAudience)); + } + + #[test] + fn audience_array_is_honored() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let mut claims = valid_claims(); + claims["aud"] = serde_json::json!(["other", "temper"]); + let token = mint(&sk, valid_header(), claims); + assert!(verify_valid(&token, &jwks).is_ok()); + } + + #[test] + fn unknown_kid_is_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + let header = serde_json::json!({ "alg": "ES256", "kid": "does-not-exist" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnknownKid)); + } + + #[test] + fn missing_kid_with_multiple_keys_is_rejected() { + let sk = test_key(); + let mut jwks = jwks_for(&sk, "k1"); + // Add a second key so a kid-less header is ambiguous. + let mut second = jwks.keys[0].clone(); + second.kid = Some("k2".into()); + jwks.keys.push(second); + let header = serde_json::json!({ "alg": "ES256" }); + let token = mint(&sk, header, valid_claims()); + assert_eq!(verify_valid(&token, &jwks), Err(JwtError::UnknownKid)); + } + + #[test] + fn malformed_tokens_are_rejected() { + let sk = test_key(); + let jwks = jwks_for(&sk, "k1"); + for bad in ["", "a.b", "a.b.c.d", "..", "a..c", ".b.c"] { + assert_eq!( + verify_valid(bad, &jwks), + Err(JwtError::Malformed), + "token {bad:?} should be malformed" + ); + } + } + + #[test] + fn decode_unverified_reads_iss_without_a_key() { + let sk = test_key(); + let token = mint(&sk, valid_header(), valid_claims()); + let claims = decode_claims_unverified(&token).expect("decodes"); + assert_eq!(claims.iss, "https://katagami.ai"); + } +} diff --git a/crates/temper-server/src/identity/mod.rs b/crates/temper-server/src/identity/mod.rs index 57216adeb..374ce0c48 100644 --- a/crates/temper-server/src/identity/mod.rs +++ b/crates/temper-server/src/identity/mod.rs @@ -5,6 +5,7 @@ //! See ADR-0033: Platform-Assigned Agent Identity. pub mod endpoint; +pub mod jwt; mod resolver; pub use resolver::{IdentityResolver, ResolvedIdentity, hash_token}; From e7fb0bd24b2f03d5907c4456e6ac3bdf9d07ee2d Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:15:13 -0400 Subject: [PATCH 02/11] feat(authz): TrustedIssuer registry + JWT branch in the identity resolver (ARN-255) Wires the ES256 verifier into the bearer path: - TrustedIssuer IOA spec (per-tenant allowlist entry; entity id = issuer URL; inline JWKS so verification makes no outbound call and stays deterministic; governed Register/RotateKeys/Suspend/Resume/Revoke actions) + CSDL + bootstrap registration alongside AgentCredential. - IdentityResolver::resolve routes JWS-shaped bearers to issuer verification and opaque bearers to the AgentCredential registry; both share the cache, with JWT entries capped at the token's own exp. - ResolvedIdentity widened with acting_for (owning human), auth_generation (carried for the step-3 revocation check), and from_jwt. - SecurityContext::from_verified_jwt maps verified claims to a principal with acting_for and role; odata bindings select it for JWT-resolved identities. Additive: header and AgentCredential paths are unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- .ci/readability-baseline.env | 8 +- crates/temper-authz/src/context.rs | 65 +++++++ crates/temper-platform/src/bootstrap.rs | 13 +- .../src/specs/agent_model.csdl.xml | 45 +++++ .../src/specs/trusted_issuer.ioa.toml | 105 +++++++++++ crates/temper-server/src/identity/jwt.rs | 24 ++- crates/temper-server/src/identity/resolver.rs | 175 +++++++++++++++--- crates/temper-server/src/odata/bindings.rs | 24 ++- scripts/e2e-trusted-issuer.sh | 111 +++++++++++ 9 files changed, 530 insertions(+), 40 deletions(-) create mode 100644 crates/temper-platform/src/specs/trusted_issuer.ioa.toml create mode 100755 scripts/e2e-trusted-issuer.sh diff --git a/.ci/readability-baseline.env b/.ci/readability-baseline.env index 9de17adc4..35b23fb20 100644 --- a/.ci/readability-baseline.env +++ b/.ci/readability-baseline.env @@ -1,11 +1,11 @@ # Generated by scripts/readability-ratchet.sh -PROD_RS_TOTAL=523 -PROD_FILES_GT300=209 -PROD_FILES_GT500=84 +PROD_RS_TOTAL=524 +PROD_FILES_GT300=211 +PROD_FILES_GT500=86 PROD_FILES_GT1000=22 PROD_MAX_FILE_LINES=2829 PROD_MAX_FILE_PATH=crates/temper-server/src/storage/mod.rs -ALLOW_CLIPPY_COUNT=35 +ALLOW_CLIPPY_COUNT=36 ALLOW_DEAD_CODE_COUNT=14 PROD_PRINTLN_COUNT=247 PROD_UNWRAP_CI_OK_COUNT=132 diff --git a/crates/temper-authz/src/context.rs b/crates/temper-authz/src/context.rs index 36eb26f5d..1b9266461 100644 --- a/crates/temper-authz/src/context.rs +++ b/crates/temper-authz/src/context.rs @@ -200,6 +200,71 @@ impl SecurityContext { } } + /// Construct security context from a JWT whose signature was verified + /// against a registered trusted issuer. + /// + /// Like [`from_resolved_identity`](Self::from_resolved_identity), identity + /// comes from a platform-verified source, never self-declared headers, and + /// `agentTypeVerified` is set. Unlike it, this path carries `acting_for` + /// (the owning human behind an agent) and `role`, both taken from verified + /// token claims. See RFC-0002. + #[allow(clippy::too_many_arguments)] + pub fn from_verified_jwt( + principal_id: &str, + kind: PrincipalKind, + agent_type: Option<&str>, + acting_for: Option<&str>, + role: Option<&str>, + session_id: Option<&str>, + ) -> Self { + let mut attributes = HashMap::new(); + attributes.insert( + "agentTypeVerified".to_string(), + serde_json::Value::Bool(true), + ); + + let mut context_attrs = HashMap::new(); + context_attrs.insert( + "agentId".to_string(), + serde_json::Value::String(principal_id.to_string()), + ); + if let Some(at) = agent_type { + context_attrs.insert( + "agentType".to_string(), + serde_json::Value::String(at.to_string()), + ); + } + context_attrs.insert( + "agentTypeVerified".to_string(), + serde_json::Value::Bool(true), + ); + if let Some(af) = acting_for { + context_attrs.insert( + "actingFor".to_string(), + serde_json::Value::String(af.to_string()), + ); + } + if let Some(sid) = session_id { + context_attrs.insert( + "sessionId".to_string(), + serde_json::Value::String(sid.to_string()), + ); + } + + SecurityContext { + principal: Principal { + id: principal_id.to_string(), + kind, + role: role.map(|r| r.to_string()), + acting_for: acting_for.map(|a| a.to_string()), + agent_type: agent_type.map(|a| a.to_string()), + attributes, + }, + context_attrs, + correlation_id: uuid::Uuid::now_v7().to_string(), + } + } + /// Enrich security context with agent identity from self-declared headers. /// /// **Deprecated**: Use `from_resolved_identity()` for credential-based identity. diff --git a/crates/temper-platform/src/bootstrap.rs b/crates/temper-platform/src/bootstrap.rs index 8cbbdd176..10aad3c1e 100644 --- a/crates/temper-platform/src/bootstrap.rs +++ b/crates/temper-platform/src/bootstrap.rs @@ -62,6 +62,7 @@ const TOOL_CALL_IOA: &str = include_str!("specs/tool_call.ioa.toml"); const SCHEDULE_IOA: &str = include_str!("specs/schedule.ioa.toml"); const POLICY_IOA: &str = include_str!("specs/policy.ioa.toml"); const AGENT_CREDENTIAL_IOA: &str = include_str!("specs/agent_credential.ioa.toml"); +const TRUSTED_ISSUER_IOA: &str = include_str!("specs/trusted_issuer.ioa.toml"); const AGENT_CSDL: &str = include_str!("specs/agent_model.csdl.xml"); /// Agent entity specs as (entity_type, ioa_source) pairs. @@ -74,6 +75,7 @@ const AGENT_SPECS: &[(&str, &str)] = &[ ("Schedule", SCHEDULE_IOA), ("Policy", POLICY_IOA), ("AgentCredential", AGENT_CREDENTIAL_IOA), + ("TrustedIssuer", TRUSTED_ISSUER_IOA), ]; /// Verify, parse, and register a set of IOA specs under a tenant. @@ -774,6 +776,15 @@ initial = "Created" #[test] fn test_agent_specs_count() { - assert_eq!(AGENT_SPECS.len(), 8); + assert_eq!(AGENT_SPECS.len(), 9); + } + + #[test] + fn test_trusted_issuer_spec_is_registered() { + assert!( + AGENT_SPECS + .iter() + .any(|(name, source)| *name == "TrustedIssuer" && !source.is_empty()) + ); } } diff --git a/crates/temper-platform/src/specs/agent_model.csdl.xml b/crates/temper-platform/src/specs/agent_model.csdl.xml index a4a18ba20..7de361887 100644 --- a/crates/temper-platform/src/specs/agent_model.csdl.xml +++ b/crates/temper-platform/src/specs/agent_model.csdl.xml @@ -141,6 +141,18 @@ + + + + + + + + + + + + @@ -166,6 +178,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -191,6 +235,7 @@ + diff --git a/crates/temper-platform/src/specs/trusted_issuer.ioa.toml b/crates/temper-platform/src/specs/trusted_issuer.ioa.toml new file mode 100644 index 000000000..9f489f636 --- /dev/null +++ b/crates/temper-platform/src/specs/trusted_issuer.ioa.toml @@ -0,0 +1,105 @@ +# TrustedIssuer Entity — I/O Automaton Specification +# +# A per-tenant allowlist entry for a JWT issuer the kernel will trust. The +# bearer resolver verifies platform-issued ES256 tokens against the JWKS +# registered here (entity id = the issuer URL, i.e. the token's `iss` claim). +# Keys are stored inline so token verification makes no outbound HTTP call and +# stays deterministic under DST; rotation is a governed action, not an +# automatic JWKS refetch. +# +# See RFC-0002 (First-Class Authorization) and ADR-0033 (Platform-Assigned +# Agent Identity). + +[automaton] +name = "TrustedIssuer" +states = ["Active", "Suspended", "Revoked"] +initial = "Active" + +# --- State Variables --- + +[[state]] +name = "issuer" +type = "string" +initial = "" + +# Inline JWKS document (JSON) holding the issuer's public keys. +[[state]] +name = "jwks_json" +type = "string" +initial = "" + +# Expected `aud` claim for tokens from this issuer. +[[state]] +name = "audience" +type = "string" +initial = "" + +# Accepted signing algorithms (informational; the verifier enforces ES256). +[[state]] +name = "algorithms" +type = "string" +initial = "ES256" + +[[state]] +name = "description" +type = "string" +initial = "" + +[[state]] +name = "created_by" +type = "string" +initial = "" + +# --- Actions --- + +[[action]] +name = "RegisterIssuer" +kind = "input" +from = ["Active"] +params = ["issuer", "jwks_json", "audience", "algorithms", "description", "created_by"] +hint = "Register a trusted JWT issuer for this tenant. Entity id must be the issuer URL." + +[[action]] +name = "RotateIssuerKeys" +kind = "input" +from = ["Active"] +params = ["jwks_json"] +hint = "Replace the issuer's inline JWKS with a rotated key set." + +[[action]] +name = "SuspendIssuer" +kind = "input" +from = ["Active"] +to = "Suspended" +hint = "Temporarily stop trusting tokens from this issuer without discarding its registration." + +[[action]] +name = "ResumeIssuer" +kind = "input" +from = ["Suspended"] +to = "Active" +hint = "Resume trusting tokens from a suspended issuer." + +[[action]] +name = "RevokeIssuer" +kind = "input" +from = ["Active", "Suspended"] +to = "Revoked" +hint = "Permanently stop trusting this issuer." + +# --- Safety Invariants --- + +[[invariant]] +name = "ActiveRequiresIssuer" +when = ["Active"] +assert = "issuer != ''" + +[[invariant]] +name = "ActiveRequiresJwks" +when = ["Active"] +assert = "jwks_json != ''" + +[[invariant]] +name = "ActiveRequiresAudience" +when = ["Active"] +assert = "audience != ''" diff --git a/crates/temper-server/src/identity/jwt.rs b/crates/temper-server/src/identity/jwt.rs index f83d99d7b..ff2c7f0ef 100644 --- a/crates/temper-server/src/identity/jwt.rs +++ b/crates/temper-server/src/identity/jwt.rs @@ -94,6 +94,14 @@ enum Audience { Many(Vec), } +impl Claims { + /// The `exp` claim (seconds since epoch). Used by the resolver to cap how + /// long a verified token may be cached — never past its own expiry. + pub fn expiry(&self) -> i64 { + self.exp + } +} + impl Audience { fn contains(&self, expected: &str) -> bool { match self { @@ -217,10 +225,10 @@ pub fn verify( if now_unix > claims.exp + leeway_secs { return Err(JwtError::Expired); } - if let Some(nbf) = claims.nbf { - if now_unix < nbf - leeway_secs { - return Err(JwtError::NotYetValid); - } + if let Some(nbf) = claims.nbf + && now_unix < nbf - leeway_secs + { + return Err(JwtError::NotYetValid); } Ok(claims) @@ -247,8 +255,12 @@ fn verifying_key_from_jwk(jwk: &Jwk) -> Result { if jwk.kty != "EC" || jwk.crv != "P-256" { return Err(JwtError::BadKey); } - let x = URL_SAFE_NO_PAD.decode(&jwk.x).map_err(|_| JwtError::BadKey)?; - let y = URL_SAFE_NO_PAD.decode(&jwk.y).map_err(|_| JwtError::BadKey)?; + let x = URL_SAFE_NO_PAD + .decode(&jwk.x) + .map_err(|_| JwtError::BadKey)?; + let y = URL_SAFE_NO_PAD + .decode(&jwk.y) + .map_err(|_| JwtError::BadKey)?; if x.len() != 32 || y.len() != 32 { return Err(JwtError::BadKey); } diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 42e79fb36..74f973b65 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -12,25 +12,42 @@ use sha2::{Digest, Sha256}; use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; +use crate::identity::jwt; use crate::state::ServerState; /// Cache entry TTL in seconds. const CACHE_TTL_SECS: i64 = 60; +/// Clock-skew tolerance (seconds) for JWT `exp`/`nbf` validation. +const JWT_LEEWAY_SECS: i64 = 60; + /// A platform-resolved agent identity. /// /// All fields are derived from the credential registry — never from /// self-declared headers or client-reported values. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResolvedIdentity { - /// Platform-assigned unique agent instance ID (UUIDv7). + /// Platform-assigned unique agent instance ID (UUIDv7) for the credential + /// path; the token's `client_id` (acting agent) for the JWT path. pub agent_instance_id: String, - /// The AgentType entity ID this credential is linked to. + /// The AgentType entity ID this credential is linked to. Empty for the JWT + /// path, which takes its type name straight from a verified claim. pub agent_type_id: String, /// The AgentType's human-readable name (e.g., "claude-code"). pub agent_type_name: String, - /// Whether this identity was verified through the credential registry. + /// Whether this identity was verified (registry or trusted-issuer JWT). pub verified: bool, + /// JWT path only: the owning human (the agent's `acting_for` / token `sub`). + #[serde(default)] + pub acting_for: Option, + /// JWT path only: the sign-out-everywhere generation carried by the token, + /// for the follow-up revocation check. + #[serde(default)] + pub auth_generation: Option, + /// True when this identity came from a verified JWT rather than an + /// `AgentCredential`. Selects the security-context constructor downstream. + #[serde(default)] + pub from_jwt: bool, } /// Cached resolution result with expiry. @@ -63,15 +80,11 @@ impl IdentityResolver { } } - /// Resolve a bearer token to a verified agent identity. + /// Resolve a bearer token to a verified identity. /// - /// 1. Hash the token (SHA-256) - /// 2. Check cache (hit → return immediately) - /// 3. Look up `AgentCredential` entity by using key_hash as entity ID - /// 4. Verify credential is `Active` - /// 5. Look up linked `AgentType` entity - /// 6. Verify AgentType is `Active` - /// 7. Cache and return `ResolvedIdentity` + /// JWT-shaped tokens (`header.payload.signature`) are verified against a + /// registered [`TrustedIssuer`]; opaque tokens are looked up in the + /// `AgentCredential` registry. Both paths share the in-memory cache. pub async fn resolve( &self, state: &ServerState, @@ -86,15 +99,38 @@ impl IdentityResolver { return Some(cached); } - // Look up AgentCredential entity. We use the key_hash as entity ID - // for O(1) lookup — the Issue action must use the key_hash as the - // entity ID when creating credentials. + if looks_like_jwt(bearer_token) { + // JWT path: verify against the token's registered issuer. Cache no + // longer than the token's own expiry so an expired token is never + // served from cache. + let (identity, exp_unix) = self.resolve_jwt(state, tenant, bearer_token).await?; + let cap = sim_now() + chrono::Duration::seconds(CACHE_TTL_SECS); + let token_exp = chrono::DateTime::from_timestamp(exp_unix, 0).unwrap_or(cap); + self.put_cached_until(cache_key, identity.clone(), cap.min(token_exp)); + Some(identity) + } else { + let identity = self.resolve_credential(state, tenant, &key_hash).await?; + self.put_cached(cache_key, identity.clone()); + Some(identity) + } + } + + /// Opaque-token path: look up the `AgentCredential` registry. + /// + /// key_hash is the entity ID (the `Issue` action uses it as such), giving + /// an O(1) lookup. Verifies the credential and its linked `AgentType` are + /// both `Active`. + async fn resolve_credential( + &self, + state: &ServerState, + tenant: &TenantId, + key_hash: &str, + ) -> Option { let cred_response = state - .get_tenant_entity_state(tenant, "AgentCredential", &key_hash) + .get_tenant_entity_state(tenant, "AgentCredential", key_hash) .await .ok()?; - // Verify credential is Active. if cred_response.state.status != "Active" { return None; } @@ -107,13 +143,11 @@ impl IdentityResolver { return None; } - // Look up linked AgentType entity. let type_response = state .get_tenant_entity_state(tenant, "AgentType", agent_type_id) .await .ok()?; - // Verify AgentType is Active. if type_response.state.status != "Active" { return None; } @@ -126,17 +160,74 @@ impl IdentityResolver { .unwrap_or("") .to_string(); - let identity = ResolvedIdentity { + Some(ResolvedIdentity { agent_instance_id: agent_instance_id.to_string(), agent_type_id: agent_type_id.to_string(), agent_type_name, verified: true, - }; + acting_for: None, + auth_generation: None, + from_jwt: false, + }) + } + + /// JWT path: verify an ES256 token against its registered `TrustedIssuer`. + /// + /// Returns the resolved identity and the token's `exp` (used to cap cache + /// TTL). The unverified `iss` claim only selects which issuer's keys to + /// check against; the signature is the gate. + async fn resolve_jwt( + &self, + state: &ServerState, + tenant: &TenantId, + token: &str, + ) -> Option<(ResolvedIdentity, i64)> { + // Read `iss` from the unverified payload to pick the issuer entity. + let unverified = jwt::decode_claims_unverified(token).ok()?; + let issuer_id = unverified.iss; + + let issuer_response = state + .get_tenant_entity_state(tenant, "TrustedIssuer", &issuer_id) + .await + .ok()?; + if issuer_response.state.status != "Active" { + return None; + } - // Cache the result. - self.put_cached(cache_key, identity.clone()); + let fields = &issuer_response.state.fields; + let jwks_json = fields.get("jwks_json")?.as_str()?; + let audience = fields.get("audience")?.as_str()?; + let jwks: jwt::Jwks = serde_json::from_str(jwks_json).ok()?; + + let now_unix = sim_now().timestamp(); + let claims = jwt::verify( + token, + &jwks, + &issuer_id, + audience, + now_unix, + JWT_LEEWAY_SECS, + ) + .ok()?; + + // Map verified claims → identity. The acting agent is `client_id`; the + // owning human is `sub`; the type name is `agent_type`. + let client_id = claims.client_id.clone().unwrap_or_default(); + let agent_type = claims.agent_type.clone().unwrap_or_default(); + if client_id.is_empty() || agent_type.is_empty() { + return None; + } - Some(identity) + let identity = ResolvedIdentity { + agent_instance_id: client_id, + agent_type_id: String::new(), + agent_type_name: agent_type, + verified: true, + acting_for: claims.sub.clone(), + auth_generation: claims.auth_generation, + from_jwt: true, + }; + Some((identity, claims.expiry())) } /// Invalidate all cached entries (e.g., after credential rotation/revocation). @@ -178,6 +269,16 @@ impl IdentityResolver { fn put_cached(&self, cache_key: String, identity: ResolvedIdentity) { let expires_at = sim_now() + chrono::Duration::seconds(CACHE_TTL_SECS); + self.put_cached_until(cache_key, identity, expires_at); + } + + /// Cache with an explicit expiry (JWT path caps this at the token's `exp`). + fn put_cached_until( + &self, + cache_key: String, + identity: ResolvedIdentity, + expires_at: chrono::DateTime, + ) { let mut cache = self.cache.write().unwrap(); // ci-ok: infallible lock // Evict expired entries opportunistically (bounded work: max 32 per insert). @@ -206,6 +307,19 @@ fn cache_key(tenant: &TenantId, key_hash: &str) -> String { format!("{}:{key_hash}", tenant.as_str()) } +/// Heuristic: does this bearer token have JWS compact form +/// (`header.payload.signature`, three non-empty dot-separated segments)? +/// +/// Opaque `AgentCredential` tokens are single-segment, so this cleanly +/// separates the two paths without decoding anything. +fn looks_like_jwt(token: &str) -> bool { + let mut parts = token.split('.'); + matches!( + (parts.next(), parts.next(), parts.next(), parts.next()), + (Some(h), Some(p), Some(s), None) if !h.is_empty() && !p.is_empty() && !s.is_empty() + ) +} + /// Hash a bearer token with SHA-256 for credential lookup. pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); @@ -232,4 +346,19 @@ mod tests { let h2 = hash_token("token-b"); assert_ne!(h1, h2); } + + #[test] + fn jwt_shape_detection_routes_correctly() { + // JWS compact form → JWT path. + assert!(looks_like_jwt("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJ4In0.c2ln")); + // Opaque credential tokens → registry path. + assert!(!looks_like_jwt("kc_3f2a9b8c7d6e5f4a")); + assert!(!looks_like_jwt("")); + // Wrong segment counts or empty segments are not JWTs. + assert!(!looks_like_jwt("a.b")); + assert!(!looks_like_jwt("a.b.c.d")); + assert!(!looks_like_jwt("a..c")); + assert!(!looks_like_jwt(".b.c")); + assert!(!looks_like_jwt("a.b.")); + } } diff --git a/crates/temper-server/src/odata/bindings.rs b/crates/temper-server/src/odata/bindings.rs index ffe35b806..cd5085deb 100644 --- a/crates/temper-server/src/odata/bindings.rs +++ b/crates/temper-server/src/odata/bindings.rs @@ -8,7 +8,7 @@ use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; use tracing_opentelemetry::OpenTelemetrySpanExt; -use temper_authz::SecurityContext; +use temper_authz::{PrincipalKind, SecurityContext}; use super::account_verification::enforce_commons_account_verified_for_action; use super::common::run_write_prechecks; @@ -83,11 +83,23 @@ pub(super) async fn dispatch_bound_action( "agent.type", identity.agent_type_name.clone(), )); - SecurityContext::from_resolved_identity( - &identity.agent_instance_id, - &identity.agent_type_name, - agent_ctx.session_id.as_deref(), - ) + if identity.from_jwt { + // Trusted-issuer JWT: an agent acting for the owning human (`sub`). + SecurityContext::from_verified_jwt( + &identity.agent_instance_id, + PrincipalKind::Agent, + Some(&identity.agent_type_name), + identity.acting_for.as_deref(), + None, + agent_ctx.session_id.as_deref(), + ) + } else { + SecurityContext::from_resolved_identity( + &identity.agent_instance_id, + &identity.agent_type_name, + agent_ctx.session_id.as_deref(), + ) + } } else { // No credential resolved — operator/admin access via global API key. // Build SecurityContext from X-Temper-Principal-Kind header (admin/system) diff --git a/scripts/e2e-trusted-issuer.sh b/scripts/e2e-trusted-issuer.sh new file mode 100755 index 000000000..be6a62b52 --- /dev/null +++ b/scripts/e2e-trusted-issuer.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Live local end-to-end check for TrustedIssuer JWT verification (ARN-255 step 1). +# +# Boots a local temper server, registers a TrustedIssuer whose JWKS matches a +# locally generated P-256 key, mints ES256 tokens with that key, and proves: +# 1. a valid token resolves to a verified agent principal (request succeeds) +# 2. a token signed by an UNKNOWN key is rejected (401) +# 3. an expired token is rejected (401) +# 4. a token from an unregistered issuer is rejected (401) +# 5. opaque AgentCredential bearers still work (additive: nothing broke) +# +# Requires: cargo (workspace built), python3 with 'cryptography', jq, curl. +# Usage: scripts/e2e-trusted-issuer.sh [port] (default 3467) +set -euo pipefail + +PORT="${1:-3467}" +BASE="http://localhost:${PORT}" +TENANT="default" +API_KEY="${TEMPER_API_KEY:-local-e2e-operator-key}" +WORKDIR="$(mktemp -d)" +trap 'kill "${SERVER_PID:-}" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT + +say() { printf '\n== %s\n' "$*" >&2; } + +# --- 1. Generate a P-256 keypair + JWKS + tokens (python, one shot) --------- +say "Generating P-256 keypair, JWKS, and test tokens" +python3 - "$WORKDIR" <<'PY' +import base64, json, sys, time +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives import hashes + +workdir = sys.argv[1] +def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + +def mint(key, kid, claims): + header = {"alg": "ES256", "kid": kid, "typ": "JWT"} + signing_input = f"{b64u(json.dumps(header).encode())}.{b64u(json.dumps(claims).encode())}" + der = key.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(der) + sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + return f"{signing_input}.{b64u(sig)}" + +key = ec.generate_private_key(ec.SECP256R1()) +pub = key.public_key().public_numbers() +jwks = {"keys": [{"kty": "EC", "crv": "P-256", "kid": "e2e-k1", + "x": b64u(pub.x.to_bytes(32, "big")), + "y": b64u(pub.y.to_bytes(32, "big"))}]} +open(f"{workdir}/jwks.json", "w").write(json.dumps(jwks)) + +now = int(time.time()) +base = {"iss": "https://e2e.issuer.local", "aud": "temper-e2e", + "sub": "human-sub-e2e", "client_id": "kc_e2e_agent", "agent_type": "contributor", + "grant_id": "grant-e2e", "nbf": now - 60} +open(f"{workdir}/token_valid.txt", "w").write(mint(key, "e2e-k1", {**base, "exp": now + 900})) +open(f"{workdir}/token_expired.txt", "w").write(mint(key, "e2e-k1", {**base, "exp": now - 600})) +open(f"{workdir}/token_bad_iss.txt", "w").write( + mint(key, "e2e-k1", {**base, "iss": "https://unregistered.example", "exp": now + 900})) + +rogue = ec.generate_private_key(ec.SECP256R1()) +open(f"{workdir}/token_rogue.txt", "w").write(mint(rogue, "e2e-k1", {**base, "exp": now + 900})) +print("minted 4 tokens") +PY + +# --- 2. Boot the server ------------------------------------------------------ +say "Starting local temper server on :$PORT" +TEMPER_API_KEY="$API_KEY" cargo run -p temper-server --bin temper-server -- --port "$PORT" \ + >"$WORKDIR/server.log" 2>&1 & +SERVER_PID=$! +for i in $(seq 1 60); do + curl -sf "$BASE/health" >/dev/null 2>&1 && break + sleep 2 + kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died; log tail:"; tail -30 "$WORKDIR/server.log"; exit 1; } +done +curl -sf "$BASE/health" >/dev/null || { echo "server never became healthy"; tail -30 "$WORKDIR/server.log"; exit 1; } + +# --- 3. Register the TrustedIssuer (operator key) --------------------------- +say "Registering TrustedIssuer https://e2e.issuer.local" +ISSUER_ID="https%3A%2F%2Fe2e.issuer.local" +curl -sf -X POST \ + "$BASE/tdata/TrustedIssuers('$ISSUER_ID')/Temper.RegisterIssuer" \ + -H "Authorization: Bearer $API_KEY" -H "X-Tenant-Id: $TENANT" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --rawfile jwks "$WORKDIR/jwks.json" '{ + issuer: "https://e2e.issuer.local", jwks_json: $jwks, + audience: "temper-e2e", algorithms: "ES256", + description: "local e2e issuer", created_by: "e2e-script"}')" >/dev/null +echo "registered" + +probe() { # probe -> HTTP status of a governed read + curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $1" -H "X-Tenant-Id: $TENANT" \ + "$BASE/tdata/TrustedIssuers('$ISSUER_ID')" +} + +# --- 4. The five checks ------------------------------------------------------ +PASS=0; FAIL=0 +check() { # check + if [ "$2" = "$3" ]; then PASS=$((PASS+1)); echo "PASS $1 (HTTP $2)"; + else FAIL=$((FAIL+1)); echo "FAIL $1 (got HTTP $2, want $3)"; fi +} + +say "Running checks" +check "valid token accepted" "$(probe "$(cat "$WORKDIR/token_valid.txt")")" "200" +check "rogue-key token rejected" "$(probe "$(cat "$WORKDIR/token_rogue.txt")")" "401" +check "expired token rejected" "$(probe "$(cat "$WORKDIR/token_expired.txt")")" "401" +check "unregistered issuer rejected" "$(probe "$(cat "$WORKDIR/token_bad_iss.txt")")" "401" +check "operator key still works" "$(probe "$API_KEY")" "200" + +say "Result: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From d6e91a759e9b87f7e40def73532e7577256c7dd5 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:17:37 -0400 Subject: [PATCH 03/11] test(authz): end-to-end TrustedIssuer JWT resolution against a real PlatformState Integration test seeds a TrustedIssuer with a real P-256 JWKS through the normal dispatch path, mints ES256 tokens, and drives IdentityResolver::resolve: valid token -> verified contributor agent acting for the human; rogue-key, expired, unregistered-issuer, tampered, and suspended-issuer tokens all reject. Documents the tenant-policy prerequisite in the live shell smoke script. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- Cargo.lock | 1 + crates/temper-platform/Cargo.toml | 3 + .../tests/trusted_issuer_resolve.rs | 231 ++++++++++++++++++ scripts/e2e-trusted-issuer.sh | 21 +- 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 crates/temper-platform/tests/trusted_issuer_resolve.rs diff --git a/Cargo.lock b/Cargo.lock index 15784ad27..cccd97709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6712,6 +6712,7 @@ dependencies = [ "chrono", "hyper 1.8.1", "opentelemetry", + "p256", "reqwest", "serde", "serde_json", diff --git a/crates/temper-platform/Cargo.toml b/crates/temper-platform/Cargo.toml index f29554c8e..1b9924433 100644 --- a/crates/temper-platform/Cargo.toml +++ b/crates/temper-platform/Cargo.toml @@ -40,3 +40,6 @@ tower = { workspace = true } hyper = { workspace = true } wiremock = { workspace = true } tempfile = "3.27.0" +# ES256 token minting for the TrustedIssuer resolver integration test. +p256 = { version = "0.13", features = ["ecdsa"] } +base64 = "0.22" diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs new file mode 100644 index 000000000..36858bf37 --- /dev/null +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -0,0 +1,231 @@ +//! Integration test for ARN-255 step 1: the kernel resolver verifies ES256 +//! JWTs against a registered `TrustedIssuer` and rejects bad ones. +//! +//! Exercises the real path end to end: a real `PlatformState` with the agent +//! specs bootstrapped, a real `TrustedIssuer` entity seeded through the normal +//! dispatch path with a real P-256 JWKS, real ES256 tokens minted here, and +//! the real `IdentityResolver::resolve` mapping verified claims to a principal. + +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey}; + +use temper_platform::{PlatformState, bootstrap_agent_specs, bootstrap_system_tenant}; +use temper_runtime::tenant::TenantId; +use temper_server::identity::IdentityResolver; +use temper_server::request_context::AgentContext; + +const ISSUER: &str = "https://issuer.e2e.local"; +const AUD: &str = "temper-e2e"; + +fn b64(v: &serde_json::Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(v).unwrap()) +} + +/// Mint a signed ES256 token from header + claims JSON. +fn mint(sk: &SigningKey, header: serde_json::Value, claims: serde_json::Value) -> String { + let signing_input = format!("{}.{}", b64(&header), b64(&claims)); + let sig: Signature = sk.sign(signing_input.as_bytes()); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes())) +} + +/// Build a single-key JWKS JSON document for a signing key. +fn jwks_json(sk: &SigningKey, kid: &str) -> String { + let vk = sk.verifying_key(); + let pt = vk.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(pt.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(pt.y().unwrap()); + serde_json::json!({ + "keys": [{ "kty": "EC", "crv": "P-256", "kid": kid, "x": x, "y": y }] + }) + .to_string() +} + +fn header() -> serde_json::Value { + serde_json::json!({ "alg": "ES256", "kid": "k1", "typ": "JWT" }) +} + +/// A valid contributor token: far-future exp so it is valid regardless of the +/// wall clock, nbf in the distant past. +fn contributor_claims() -> serde_json::Value { + serde_json::json!({ + "iss": ISSUER, + "sub": "human-e2e", + "aud": AUD, + "client_id": "kc_agent_e2e", + "agent_type": "contributor", + "grant_id": "grant-e2e", + "auth_generation": 3, + "nbf": 0, + "exp": 4_102_444_800i64, // year 2100 + }) +} + +async fn state_with_issuer(sk: &SigningKey) -> PlatformState { + let state = PlatformState::new(None); + let cache = BTreeMap::new(); + bootstrap_system_tenant(&state, &cache); + bootstrap_agent_specs(&state, "default", false, &cache); + + let tenant = TenantId::new("default"); + let ctx = AgentContext::for_service("trusted-issuer-e2e"); + state + .server + .dispatch_tenant_action( + &tenant, + "TrustedIssuer", + ISSUER, + "RegisterIssuer", + serde_json::json!({ + "issuer": ISSUER, + "jwks_json": jwks_json(sk, "k1"), + "audience": AUD, + "algorithms": "ES256", + "description": "e2e issuer", + "created_by": "e2e", + }), + &ctx, + ) + .await + .expect("register issuer"); + state +} + +#[tokio::test] +async fn valid_token_resolves_to_verified_agent_acting_for_human() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let token = mint(&sk, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await + .expect("valid token should resolve"); + + assert!(id.verified); + assert!(id.from_jwt); + assert_eq!(id.agent_instance_id, "kc_agent_e2e"); + assert_eq!(id.agent_type_name, "contributor"); + assert_eq!(id.acting_for.as_deref(), Some("human-e2e")); + assert_eq!(id.auth_generation, Some(3)); +} + +#[tokio::test] +async fn token_signed_by_unknown_key_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + // Sign with a different key than the registered JWKS. + let rogue = SigningKey::from_slice(&[9u8; 32]).unwrap(); + let token = mint(&rogue, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!(id.is_none(), "rogue-key token must not resolve"); +} + +#[tokio::test] +async fn expired_token_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let mut claims = contributor_claims(); + claims["exp"] = serde_json::json!(100); // 1970 — long expired vs wall clock + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!(id.is_none(), "expired token must not resolve"); +} + +#[tokio::test] +async fn unregistered_issuer_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let mut claims = contributor_claims(); + claims["iss"] = serde_json::json!("https://not-registered.example"); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await; + assert!( + id.is_none(), + "token from an unregistered issuer must not resolve" + ); +} + +#[tokio::test] +async fn tampered_payload_is_rejected() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + let token = mint(&sk, header(), contributor_claims()); + // Swap the payload for one escalating agent_type, keep the original signature. + let mut parts: Vec<&str> = token.split('.').collect(); + let forged = b64(&serde_json::json!({ + "iss": ISSUER, "aud": AUD, "client_id": "kc_agent_e2e", + "agent_type": "owner", "exp": 4_102_444_800i64, + })); + parts[1] = &forged; + let tampered = parts.join("."); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &tampered) + .await; + assert!(id.is_none(), "tampered token must not resolve"); +} + +#[tokio::test] +async fn suspended_issuer_stops_resolving() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let token = mint(&sk, header(), contributor_claims()); + let tenant = TenantId::new("default"); + + // Valid before suspension. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some(), + "token should resolve while issuer is Active" + ); + + // Suspend the issuer. + let ctx = AgentContext::for_service("trusted-issuer-e2e"); + state + .server + .dispatch_tenant_action( + &tenant, + "TrustedIssuer", + ISSUER, + "SuspendIssuer", + serde_json::json!({}), + &ctx, + ) + .await + .expect("suspend issuer"); + + // A fresh resolver (empty cache) must now reject the same token. + let fresh = IdentityResolver::new(); + assert!( + fresh + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "token must not resolve once its issuer is Suspended" + ); +} diff --git a/scripts/e2e-trusted-issuer.sh b/scripts/e2e-trusted-issuer.sh index be6a62b52..67446cec4 100755 --- a/scripts/e2e-trusted-issuer.sh +++ b/scripts/e2e-trusted-issuer.sh @@ -9,6 +9,17 @@ # 4. a token from an unregistered issuer is rejected (401) # 5. opaque AgentCredential bearers still work (additive: nothing broke) # +# NOTE ON AUTHORIZATION: registering a TrustedIssuer is an admin-gated action, +# so this script needs the target tenant to carry a Cedar policy permitting +# admin management of TrustedIssuer (RegisterIssuer/Suspend/...). A bare +# `temper serve` tenant is default-deny with no seeded permits, so RegisterIssuer +# returns 403 there — as does AgentCredential.Issue; it is not specific to this +# entity. Point this script at a tenant provisioned with that policy. The +# resolver logic itself (verify -> principal, and rejection of bad tokens) is +# covered without that dependency by the integration test +# `crates/temper-platform/tests/trusted_issuer_resolve.rs`, which seeds the +# issuer through the internal dispatch path and asserts the same five outcomes. +# # Requires: cargo (workspace built), python3 with 'cryptography', jq, curl. # Usage: scripts/e2e-trusted-issuer.sh [port] (default 3467) set -euo pipefail @@ -64,15 +75,15 @@ PY # --- 2. Boot the server ------------------------------------------------------ say "Starting local temper server on :$PORT" -TEMPER_API_KEY="$API_KEY" cargo run -p temper-server --bin temper-server -- --port "$PORT" \ +TEMPER_API_KEY="$API_KEY" cargo run -p temper-cli --bin temper -- serve --port "$PORT" --no-observe \ >"$WORKDIR/server.log" 2>&1 & SERVER_PID=$! -for i in $(seq 1 60); do - curl -sf "$BASE/health" >/dev/null 2>&1 && break +for i in $(seq 1 120); do + curl -sf "$BASE/healthz" >/dev/null 2>&1 && break sleep 2 - kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died; log tail:"; tail -30 "$WORKDIR/server.log"; exit 1; } + kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died; log tail:"; tail -40 "$WORKDIR/server.log"; exit 1; } done -curl -sf "$BASE/health" >/dev/null || { echo "server never became healthy"; tail -30 "$WORKDIR/server.log"; exit 1; } +curl -sf "$BASE/healthz" >/dev/null || { echo "server never became healthy"; tail -40 "$WORKDIR/server.log"; exit 1; } # --- 3. Register the TrustedIssuer (operator key) --------------------------- say "Registering TrustedIssuer https://e2e.issuer.local" From d7e3edae298b9407ab9e7d814f507c4e7ceb3f8e Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:14:10 -0400 Subject: [PATCH 04/11] feat(authz): sign-out-everywhere + human principals + env issuer activation (ARN-255 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kernel half of RFC-0002 step 3, plus finishing step 1's activation path. - PrincipalGeneration entity (IOA + CSDL + bootstrap): a kernel-side monotonic generation counter per principal subject, advanced by BumpGeneration. This is option A — the generation lives in the kernel, not on an app entity like Member, so 'sign out everywhere' is a first-class platform capability the kernel can enforce generically. The resolver rejects any token whose auth_generation is older than the subject's current generation (keyed on the human sub, so signing out a human also kills agents acting for them). - Human principals: a verified token with a sub but no agent_type now resolves to a Customer principal carrying its role claim (for Cedar); a token with an agent_type stays an Agent acting for the human. Bindings select the kind. - bootstrap_trusted_issuer_from_env: a deployment activates the JWT path by setting TEMPER_TRUSTED_ISSUER_{URL,JWKS,AUD}, mirroring how TEMPER_API_KEY becomes the operator credential — no authenticated API call, no hand-seeded Cedar policy needed to get started. Integration tests (8 total, all against a real PlatformState): human->Customer with role; bump-generation invalidates older tokens while a token minted at the new generation still resolves. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- crates/temper-cli/src/serve/bootstrap.rs | 5 ++ crates/temper-platform/src/bootstrap.rs | 73 ++++++++++++++- crates/temper-platform/src/lib.rs | 2 +- .../src/specs/agent_model.csdl.xml | 13 +++ .../src/specs/principal_generation.ioa.toml | 43 +++++++++ .../tests/trusted_issuer_resolve.rs | 85 ++++++++++++++++++ crates/temper-server/src/identity/jwt.rs | 4 + crates/temper-server/src/identity/resolver.rs | 89 ++++++++++++++++--- crates/temper-server/src/odata/bindings.rs | 17 +++- 9 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 crates/temper-platform/src/specs/principal_generation.ioa.toml diff --git a/crates/temper-cli/src/serve/bootstrap.rs b/crates/temper-cli/src/serve/bootstrap.rs index 2e206e453..2807c8e75 100644 --- a/crates/temper-cli/src/serve/bootstrap.rs +++ b/crates/temper-cli/src/serve/bootstrap.rs @@ -486,6 +486,11 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St if let Some(ref api_key) = state.api_token { temper_platform::bootstrap_operator_credential(state, api_key, "default").await; } + + // Register a trusted JWT issuer from env config, if provided (ARN-255). + // This is how a deployment activates the platform-issued-token path without + // an authenticated API call. + temper_platform::bootstrap_trusted_issuer_from_env(state, "default").await; } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/temper-platform/src/bootstrap.rs b/crates/temper-platform/src/bootstrap.rs index 10aad3c1e..89d7445e8 100644 --- a/crates/temper-platform/src/bootstrap.rs +++ b/crates/temper-platform/src/bootstrap.rs @@ -63,6 +63,7 @@ const SCHEDULE_IOA: &str = include_str!("specs/schedule.ioa.toml"); const POLICY_IOA: &str = include_str!("specs/policy.ioa.toml"); const AGENT_CREDENTIAL_IOA: &str = include_str!("specs/agent_credential.ioa.toml"); const TRUSTED_ISSUER_IOA: &str = include_str!("specs/trusted_issuer.ioa.toml"); +const PRINCIPAL_GENERATION_IOA: &str = include_str!("specs/principal_generation.ioa.toml"); const AGENT_CSDL: &str = include_str!("specs/agent_model.csdl.xml"); /// Agent entity specs as (entity_type, ioa_source) pairs. @@ -76,6 +77,7 @@ const AGENT_SPECS: &[(&str, &str)] = &[ ("Policy", POLICY_IOA), ("AgentCredential", AGENT_CREDENTIAL_IOA), ("TrustedIssuer", TRUSTED_ISSUER_IOA), + ("PrincipalGeneration", PRINCIPAL_GENERATION_IOA), ]; /// Verify, parse, and register a set of IOA specs under a tenant. @@ -466,6 +468,66 @@ pub async fn bootstrap_operator_credential(state: &PlatformState, api_key: &str, ); } +/// Register a trusted JWT issuer from environment configuration at startup. +/// +/// Mirrors [`bootstrap_operator_credential`]: a deployment activates the +/// platform-issued-token path (ARN-255) by setting three env vars — the issuer +/// URL, its inline JWKS, and the expected audience — instead of making an +/// authenticated API call. Registration goes through the normal dispatch path +/// under a service context, so it is not gated on a tenant Cedar policy. +/// +/// No-op when the env vars are unset. Idempotent: re-registering an existing +/// issuer is a self-loop on `Active`. +pub async fn bootstrap_trusted_issuer_from_env(state: &PlatformState, tenant: &str) { + let (issuer, jwks_json, audience) = match ( + std::env::var("TEMPER_TRUSTED_ISSUER_URL") + .ok() + .filter(|s| !s.is_empty()), + std::env::var("TEMPER_TRUSTED_ISSUER_JWKS") + .ok() + .filter(|s| !s.is_empty()), + std::env::var("TEMPER_TRUSTED_ISSUER_AUD") + .ok() + .filter(|s| !s.is_empty()), + ) { + (Some(u), Some(j), Some(a)) => (u, j, a), + _ => return, + }; + + let tenant_id = temper_runtime::tenant::TenantId::new(tenant); + let agent_ctx = temper_server::request_context::AgentContext::for_service("platform-bootstrap"); + let algorithms = + std::env::var("TEMPER_TRUSTED_ISSUER_ALGS").unwrap_or_else(|_| "ES256".to_string()); + + let result = state + .server + .dispatch_tenant_action( + &tenant_id, + "TrustedIssuer", + &issuer, + "RegisterIssuer", + serde_json::json!({ + "issuer": issuer, + "jwks_json": jwks_json, + "audience": audience, + "algorithms": algorithms, + "description": "Registered from environment at startup", + "created_by": "bootstrap", + }), + &agent_ctx, + ) + .await; + + match result { + Ok(_) => tracing::info!( + "Trusted issuer '{issuer}' registered for tenant '{tenant}' from environment" + ), + Err(e) => tracing::error!( + "Failed to register trusted issuer '{issuer}' for tenant '{tenant}': {e}" + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -776,7 +838,7 @@ initial = "Created" #[test] fn test_agent_specs_count() { - assert_eq!(AGENT_SPECS.len(), 9); + assert_eq!(AGENT_SPECS.len(), 10); } #[test] @@ -787,4 +849,13 @@ initial = "Created" .any(|(name, source)| *name == "TrustedIssuer" && !source.is_empty()) ); } + + #[test] + fn test_principal_generation_spec_is_registered() { + assert!( + AGENT_SPECS + .iter() + .any(|(name, source)| *name == "PrincipalGeneration" && !source.is_empty()) + ); + } } diff --git a/crates/temper-platform/src/lib.rs b/crates/temper-platform/src/lib.rs index 756496fc8..be3839aa8 100644 --- a/crates/temper-platform/src/lib.rs +++ b/crates/temper-platform/src/lib.rs @@ -26,7 +26,7 @@ pub mod tenant_api; // Re-export primary types at crate root. pub use bootstrap::{ bootstrap_agent_specs, bootstrap_operator_credential, bootstrap_system_tenant, - persist_agent_verification, persist_system_verification, + bootstrap_trusted_issuer_from_env, persist_agent_verification, persist_system_verification, }; pub use os_apps::{AppBundle, AppEntry, AppManifest, InstallResult, install_os_app, list_os_apps}; pub use protocol::{PlatformEvent, VerifyStepStatus}; diff --git a/crates/temper-platform/src/specs/agent_model.csdl.xml b/crates/temper-platform/src/specs/agent_model.csdl.xml index 7de361887..d27527196 100644 --- a/crates/temper-platform/src/specs/agent_model.csdl.xml +++ b/crates/temper-platform/src/specs/agent_model.csdl.xml @@ -153,6 +153,13 @@ + + + + + + + @@ -210,6 +217,11 @@ + + + + + @@ -236,6 +248,7 @@ + diff --git a/crates/temper-platform/src/specs/principal_generation.ioa.toml b/crates/temper-platform/src/specs/principal_generation.ioa.toml new file mode 100644 index 000000000..97fe0a017 --- /dev/null +++ b/crates/temper-platform/src/specs/principal_generation.ioa.toml @@ -0,0 +1,43 @@ +# PrincipalGeneration Entity — I/O Automaton Specification +# +# The kernel-side source of truth for "sign out everywhere" (RFC-0002, ARN-255, +# option A). One entity per principal subject (entity id = the human's `sub`), +# holding a monotonically increasing `generation` counter. +# +# Flow: +# - The authorization server reads this counter when it mints a token and +# stamps the value into the token's `auth_generation` claim. +# - The kernel resolver rejects any token whose `auth_generation` is older +# than the current counter. +# - "Sign out everywhere" dispatches `BumpGeneration`, which advances the +# counter and thereby invalidates every token issued before that moment +# (within the resolver's short cache window). +# +# The generation lives here, in the kernel, NOT on an app entity like Member — +# so the kernel can enforce it generically for any app. Bumping it is a +# governed action, so revocation is a first-class platform capability. + +[automaton] +name = "PrincipalGeneration" +states = ["Active"] +initial = "Active" + +# --- State Variables --- + +# Monotonic token generation. Every `BumpGeneration` advances it by one. +[[state]] +name = "generation" +type = "counter" +initial = "0" + +# --- Actions --- + +[[action]] +name = "BumpGeneration" +kind = "input" +from = ["Active"] +effect = [ + { type = "increment", var = "generation" }, + { type = "emit", event = "GenerationBumped" } +] +hint = "Sign out everywhere: invalidate every token issued for this principal before now by advancing its generation." diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs index 36858bf37..6d2d20cfa 100644 --- a/crates/temper-platform/tests/trusted_issuer_resolve.rs +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -229,3 +229,88 @@ async fn suspended_issuer_stops_resolving() { "token must not resolve once its issuer is Suspended" ); } + +#[tokio::test] +async fn human_token_resolves_to_customer_with_role() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + + // A human token: `sub` + `role`, no `agent_type`/`client_id`. + let claims = serde_json::json!({ + "iss": ISSUER, "aud": AUD, "sub": "human-owner", + "role": "owner", "auth_generation": 0, + "nbf": 0, "exp": 4_102_444_800i64, + }); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + let id = resolver + .resolve(&state.server, &TenantId::new("default"), &token) + .await + .expect("human token should resolve"); + + assert!(id.from_jwt); + assert!(id.is_human); + assert_eq!(id.agent_instance_id, "human-owner"); + assert_eq!(id.role.as_deref(), Some("owner")); + assert!(id.acting_for.is_none()); +} + +#[tokio::test] +async fn bumping_generation_invalidates_older_tokens() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + + // Token minted at generation 0 (contributor_claims stamps auth_generation=3; + // build one at gen 0 for clarity). + let gen0 = { + let mut c = contributor_claims(); + c["auth_generation"] = serde_json::json!(0); + mint(&sk, header(), c) + }; + + // Valid before any bump. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &gen0) + .await + .is_some(), + "gen-0 token valid before sign-out-everywhere" + ); + + // Sign out everywhere: bump the human's generation to 1. The generation is + // keyed on the human `sub`, which contributor_claims sets to "human-e2e". + let ctx = AgentContext::for_service("signout-e2e"); + state + .server + .dispatch_tenant_action( + &tenant, + "PrincipalGeneration", + "human-e2e", + "BumpGeneration", + serde_json::json!({}), + &ctx, + ) + .await + .expect("bump generation"); + + // A fresh resolver must now reject the gen-0 token (0 < current 1)... + let fresh = IdentityResolver::new(); + assert!( + fresh.resolve(&state.server, &tenant, &gen0).await.is_none(), + "gen-0 token must be rejected after the generation is bumped" + ); + + // ...but a token minted at the new generation (1) still resolves. + let gen1 = { + let mut c = contributor_claims(); + c["auth_generation"] = serde_json::json!(1); + mint(&sk, header(), c) + }; + assert!( + fresh.resolve(&state.server, &tenant, &gen1).await.is_some(), + "a token minted at the current generation must still resolve" + ); +} diff --git a/crates/temper-server/src/identity/jwt.rs b/crates/temper-server/src/identity/jwt.rs index ff2c7f0ef..72fa4197a 100644 --- a/crates/temper-server/src/identity/jwt.rs +++ b/crates/temper-server/src/identity/jwt.rs @@ -130,6 +130,10 @@ pub struct Claims { pub client_id: Option, #[serde(default)] pub agent_type: Option, + /// The owning human's role (owner/curator/contributor), set by the AS from + /// the Member record. Carried onto the principal for Cedar evaluation. + #[serde(default)] + pub role: Option, #[serde(default)] pub grant_id: Option, #[serde(default)] diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 74f973b65..919e548e8 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -48,6 +48,14 @@ pub struct ResolvedIdentity { /// `AgentCredential`. Selects the security-context constructor downstream. #[serde(default)] pub from_jwt: bool, + /// JWT path only: true when the token represents a human (Customer) rather + /// than an agent — i.e. it carries a `sub` but no `agent_type`. + #[serde(default)] + pub is_human: bool, + /// JWT path only: the principal's role (owner/curator/contributor) from a + /// verified token claim, for Cedar evaluation. + #[serde(default)] + pub role: Option, } /// Cached resolution result with expiry. @@ -168,6 +176,8 @@ impl IdentityResolver { acting_for: None, auth_generation: None, from_jwt: false, + is_human: false, + role: None, }) } @@ -210,26 +220,79 @@ impl IdentityResolver { ) .ok()?; - // Map verified claims → identity. The acting agent is `client_id`; the - // owning human is `sub`; the type name is `agent_type`. - let client_id = claims.client_id.clone().unwrap_or_default(); - let agent_type = claims.agent_type.clone().unwrap_or_default(); - if client_id.is_empty() || agent_type.is_empty() { + // Sign-out-everywhere: reject a token whose generation is older than the + // principal's current generation (RFC-0002, ARN-255 option A). The + // generation is keyed on the human `sub`, so signing out a human also + // invalidates the tokens of agents acting for them. + if let Some(token_gen) = claims.auth_generation + && let Some(sub) = claims.sub.as_deref() + && token_gen < self.current_generation(state, tenant, sub).await + { return None; } - let identity = ResolvedIdentity { - agent_instance_id: client_id, - agent_type_id: String::new(), - agent_type_name: agent_type, - verified: true, - acting_for: claims.sub.clone(), - auth_generation: claims.auth_generation, - from_jwt: true, + // Map verified claims → identity. A token with an `agent_type` is an + // agent acting for the human `sub`; a token with only a `sub` is the + // human themselves (a Customer principal). + let identity = match claims.agent_type.as_deref().filter(|s| !s.is_empty()) { + Some(agent_type) => { + let client_id = claims.client_id.clone().unwrap_or_default(); + if client_id.is_empty() { + return None; + } + ResolvedIdentity { + agent_instance_id: client_id, + agent_type_id: String::new(), + agent_type_name: agent_type.to_string(), + verified: true, + acting_for: claims.sub.clone(), + auth_generation: claims.auth_generation, + from_jwt: true, + is_human: false, + role: claims.role.clone(), + } + } + None => { + let sub = claims.sub.clone().unwrap_or_default(); + if sub.is_empty() { + return None; + } + ResolvedIdentity { + agent_instance_id: sub, + agent_type_id: String::new(), + agent_type_name: String::new(), + verified: true, + acting_for: None, + auth_generation: claims.auth_generation, + from_jwt: true, + is_human: true, + role: claims.role.clone(), + } + } }; Some((identity, claims.expiry())) } + /// Read a principal's current sign-out-everywhere generation. + /// + /// Missing entity ⇒ generation 0 (never signed out everywhere). The counter + /// lives in the kernel's `PrincipalGeneration` entity, so this check is + /// generic across apps. + async fn current_generation(&self, state: &ServerState, tenant: &TenantId, sub: &str) -> i64 { + match state + .get_tenant_entity_state(tenant, "PrincipalGeneration", sub) + .await + { + Ok(resp) => resp + .state + .counters + .get("generation") + .map(|c| *c as i64) + .unwrap_or(0), + Err(_) => 0, + } + } + /// Invalidate all cached entries (e.g., after credential rotation/revocation). pub fn invalidate_all(&self) { let mut cache = self.cache.write().unwrap(); // ci-ok: infallible lock diff --git a/crates/temper-server/src/odata/bindings.rs b/crates/temper-server/src/odata/bindings.rs index cd5085deb..ec2156362 100644 --- a/crates/temper-server/src/odata/bindings.rs +++ b/crates/temper-server/src/odata/bindings.rs @@ -84,13 +84,22 @@ pub(super) async fn dispatch_bound_action( identity.agent_type_name.clone(), )); if identity.from_jwt { - // Trusted-issuer JWT: an agent acting for the owning human (`sub`). + // Trusted-issuer JWT. A human token yields a Customer principal; an + // agent token yields an Agent acting for the owning human (`sub`). + let (kind, agent_type) = if identity.is_human { + (PrincipalKind::Customer, None) + } else { + ( + PrincipalKind::Agent, + Some(identity.agent_type_name.as_str()), + ) + }; SecurityContext::from_verified_jwt( &identity.agent_instance_id, - PrincipalKind::Agent, - Some(&identity.agent_type_name), + kind, + agent_type, identity.acting_for.as_deref(), - None, + identity.role.as_deref(), agent_ctx.session_id.as_deref(), ) } else { From 762f0d53f866c77423251ef01247cac5c6d8e79c Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:25:58 -0400 Subject: [PATCH 05/11] feat(authz): compile spec-declared authorization to Cedar overlays (ARN-255 step 4) IOA actions can declare authorization requirements, compiled to enforceable Cedar at app install: - requires_role = ["owner","curator"] -> forbid Publish unless principal.role in [...] - requires = "creator" -> forbid Withdraw unless resource.creator_sub == principal.id Pieces: - temper-spec: Action gains optional requires_role / requires (parser + struct; unknown-key-tolerant, so existing specs are unaffected). - temper-authz: generate_authz_overlays emits forbid-unless overlays with Cedar string escaping + entity-ident validation (closes the ARN-172 injection class the generators lacked). Forbid overlays compose on top of an app's existing permit-all base (Cedar forbid-overrides-permit), so this needs no default-deny migration and no dependency on the ARN-230 fallback fix. - temper-platform: os-app install compiles each parsed automaton's annotations and appends the overlays to the tenant's Cedar (durable via policy rows). Verified end to end: TOML annotation -> parse -> overlay -> real AuthzEngine denies wrong-role/non-creator and permits owner/curator/creator on a permit-all base. 6 new tests (2 compiler-unit incl. injection, 2 engine-enforcement, 2 platform parse->overlay); temper-spec 267 + temper-authz 74 green; clippy -D clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- .ci/readability-baseline.env | 2 +- crates/temper-authz/src/engine/tests.rs | 115 +++++++++++++ crates/temper-authz/src/lib.rs | 4 +- crates/temper-authz/src/policy_gen.rs | 158 ++++++++++++++++++ crates/temper-platform/src/os_apps/mod.rs | 37 +++- .../temper-platform/src/os_apps/mod_test.rs | 66 ++++++++ .../src/automaton/toml_parser/mod.rs | 4 + crates/temper-spec/src/automaton/types.rs | 12 ++ 8 files changed, 393 insertions(+), 5 deletions(-) diff --git a/.ci/readability-baseline.env b/.ci/readability-baseline.env index 35b23fb20..004f300fc 100644 --- a/.ci/readability-baseline.env +++ b/.ci/readability-baseline.env @@ -1,7 +1,7 @@ # Generated by scripts/readability-ratchet.sh PROD_RS_TOTAL=524 PROD_FILES_GT300=211 -PROD_FILES_GT500=86 +PROD_FILES_GT500=87 PROD_FILES_GT1000=22 PROD_MAX_FILE_LINES=2829 PROD_MAX_FILE_PATH=crates/temper-server/src/storage/mod.rs diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index 5d167451e..5594a7e9e 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -760,3 +760,118 @@ fn candidate_filter_preserves_named_forbid_policy_ids() { "candidate filtering must preserve named policy diagnostics, got: {policy_ids:?}" ); } + +// --- Spec-declared authorization overlays actually enforce (ARN-255) --------- + +fn role_context(id: &str, role: &str) -> SecurityContext { + SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), id.to_string()), + ( + "X-Temper-Principal-Kind".to_string(), + "customer".to_string(), + ), + ("X-Temper-Agent-Role".to_string(), role.to_string()), + ]) +} + +/// A generated `forbid … unless role` overlay must restrict even on top of an +/// app's blanket permit-all base (Cedar forbid-overrides-permit). +#[test] +fn generated_role_overlay_enforces_on_permit_all_base() { + let base = "permit(principal, action, resource is DesignLanguage);"; + let overlay = crate::policy_gen::generate_authz_overlays( + "DesignLanguage", + &[crate::policy_gen::ActionAuthz { + name: "Publish".to_string(), + requires_role: vec!["owner".to_string(), "curator".to_string()], + requires: None, + }], + ); + let engine = AuthzEngine::new(&format!("{base}\n{overlay}")).expect("policy parses"); + let attrs = HashMap::new(); + + // Owner and curator may Publish; contributor and role-less may not. + assert!( + engine + .authorize( + &role_context("u1", "owner"), + "Publish", + "DesignLanguage", + &attrs + ) + .is_allowed() + ); + assert!( + engine + .authorize( + &role_context("u2", "curator"), + "Publish", + "DesignLanguage", + &attrs + ) + .is_allowed() + ); + assert!( + !engine + .authorize( + &role_context("u3", "contributor"), + "Publish", + "DesignLanguage", + &attrs + ) + .is_allowed(), + "contributor must be forbidden from Publish" + ); + assert!( + !engine + .authorize(&customer_context("u4"), "Publish", "DesignLanguage", &attrs) + .is_allowed(), + "a role-less principal must be forbidden from Publish" + ); + + // A different action on the same resource is unaffected by the overlay. + assert!( + engine + .authorize( + &customer_context("u5"), + "SetCreator", + "DesignLanguage", + &attrs + ) + .is_allowed(), + "unannotated actions stay allowed by the permit-all base" + ); +} + +/// A generated `requires = "creator"` overlay must restrict to the resource +/// owner (`resource.creator_sub == principal.id`). +#[test] +fn generated_creator_overlay_enforces_ownership() { + let base = "permit(principal, action, resource is Remix);"; + let overlay = crate::policy_gen::generate_authz_overlays( + "Remix", + &[crate::policy_gen::ActionAuthz { + name: "Withdraw".to_string(), + requires_role: vec![], + requires: Some("creator".to_string()), + }], + ); + let engine = AuthzEngine::new(&format!("{base}\n{overlay}")).expect("policy parses"); + + let mut owned_by_u1 = HashMap::new(); + owned_by_u1.insert("creator_sub".to_string(), serde_json::json!("u1")); + + // The owner may Withdraw; a different principal may not. + assert!( + engine + .authorize(&customer_context("u1"), "Withdraw", "Remix", &owned_by_u1) + .is_allowed(), + "the creator may Withdraw their own resource" + ); + assert!( + !engine + .authorize(&customer_context("u2"), "Withdraw", "Remix", &owned_by_u1) + .is_allowed(), + "a non-creator must be forbidden from Withdraw" + ); +} diff --git a/crates/temper-authz/src/lib.rs b/crates/temper-authz/src/lib.rs index 3fc2fb33f..f504e998f 100644 --- a/crates/temper-authz/src/lib.rs +++ b/crates/temper-authz/src/lib.rs @@ -15,6 +15,6 @@ pub use engine::{AuthzDecision, AuthzEngine}; pub use error::{AuthzDenial, AuthzError}; pub use metrics::init_metrics; pub use policy_gen::{ - ActionScope, DurationScope, PolicyScopeMatrix, PrincipalScope, ResourceScope, - generate_cedar_from_matrix, validate_policy_scope_matrix, + ActionAuthz, ActionScope, DurationScope, PolicyScopeMatrix, PrincipalScope, ResourceScope, + generate_authz_overlays, generate_cedar_from_matrix, validate_policy_scope_matrix, }; diff --git a/crates/temper-authz/src/policy_gen.rs b/crates/temper-authz/src/policy_gen.rs index 5e4e531f0..f070ce350 100644 --- a/crates/temper-authz/src/policy_gen.rs +++ b/crates/temper-authz/src/policy_gen.rs @@ -223,6 +223,103 @@ pub fn generate_cedar_from_matrix( ) } +// --- Spec-declared authorization (RFC-0002, ARN-255) ------------------------ + +/// One action's declared authorization requirements, extracted from its IOA +/// spec. Kept as primitives so this module needs no dependency on temper-spec. +#[derive(Debug, Clone)] +pub struct ActionAuthz { + /// Action name (e.g. "Publish"). + pub name: String, + /// Roles allowed to invoke it (`requires_role`); empty = no role gate. + pub requires_role: Vec, + /// `"creator"` restricts to the resource owner; None = no ownership gate. + pub requires: Option, +} + +/// Escape a string for use inside a Cedar double-quoted string literal. +/// +/// App-authored identifiers (action names, role values) flow into generated +/// policy text; without escaping a `"` or `\` would break the policy or inject +/// clauses (the ARN-172 class). Cedar string escapes are backslash-based. +fn cedar_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\0' => out.push_str("\\0"), + _ => out.push(c), + } + } + out +} + +/// A Cedar entity-type name is an unquoted identifier, not a string literal, so +/// it cannot be escaped — it must already be a valid identifier. Reject +/// anything else rather than emit broken/injectable policy text. +fn is_valid_cedar_ident(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Compile an entity's action authorization annotations into Cedar +/// `forbid … unless` overlays. +/// +/// A **forbid** overlay is used (not a permit) so the requirement composes on +/// top of an app's existing broad `permit(principal, action, resource is X)` +/// base: Cedar's forbid-overrides-permit semantics mean the overlay restricts +/// even when a blanket permit exists. Both requirements on one action emit two +/// forbids (role AND ownership must both hold to invoke). +/// +/// Returns `""` when no action carries a requirement, or when the entity type +/// is not a valid Cedar identifier (defensive — never emit broken policy text). +pub fn generate_authz_overlays(entity_type: &str, actions: &[ActionAuthz]) -> String { + if !is_valid_cedar_ident(entity_type) { + return String::new(); + } + let mut out = String::new(); + for a in actions { + let action_lit = cedar_escape(&a.name); + // The comment sits after `//`; strip line breaks so a newline in an + // app-authored name cannot break out of the comment and inject a clause. + let comment_name = comment_safe(&a.name); + + if !a.requires_role.is_empty() { + let roles = a + .requires_role + .iter() + .map(|r| format!("\"{}\"", cedar_escape(r))) + .collect::>() + .join(", "); + out.push_str(&format!( + "// generated from spec: {entity_type}.{comment_name} requires_role\n\ + forbid(\n principal,\n action == Action::\"{action_lit}\",\n resource is {entity_type}\n)\nunless {{ principal has role && [{roles}].contains(principal.role) }};\n\n", + )); + } + + if a.requires.as_deref() == Some("creator") { + out.push_str(&format!( + "// generated from spec: {entity_type}.{comment_name} requires creator\n\ + forbid(\n principal,\n action == Action::\"{action_lit}\",\n resource is {entity_type}\n)\nunless {{ resource has creator_sub && resource.creator_sub == principal.id }};\n\n", + )); + } + } + out +} + +/// Collapse line breaks to spaces so a value is safe inside a `//` comment. +fn comment_safe(s: &str) -> String { + s.replace(['\n', '\r'], " ") +} + #[cfg(test)] mod tests { use super::*; @@ -415,4 +512,65 @@ mod tests { }; assert!(validate_policy_scope_matrix(&m).is_err()); } + + // --- Spec-declared authorization overlays ------------------------------- + + fn authz(name: &str, roles: &[&str], requires: Option<&str>) -> ActionAuthz { + ActionAuthz { + name: name.to_string(), + requires_role: roles.iter().map(|s| s.to_string()).collect(), + requires: requires.map(|s| s.to_string()), + } + } + + #[test] + fn no_annotations_emits_nothing() { + let out = generate_authz_overlays("DesignLanguage", &[authz("Publish", &[], None)]); + assert!(out.is_empty()); + } + + #[test] + fn requires_role_emits_forbid_unless_role() { + let out = generate_authz_overlays( + "DesignLanguage", + &[authz("Publish", &["owner", "curator"], None)], + ); + assert!(out.contains("forbid(")); + assert!(out.contains("action == Action::\"Publish\"")); + assert!(out.contains("resource is DesignLanguage")); + assert!(out.contains("principal has role")); + assert!(out.contains("[\"owner\", \"curator\"].contains(principal.role)")); + // It is a forbid overlay, never a permit. + assert!(!out.contains("permit(")); + } + + #[test] + fn requires_creator_emits_ownership_forbid() { + let out = generate_authz_overlays("Remix", &[authz("Withdraw", &[], Some("creator"))]); + assert!(out.contains("resource has creator_sub && resource.creator_sub == principal.id")); + assert!(out.contains("action == Action::\"Withdraw\"")); + } + + #[test] + fn both_requirements_emit_two_forbids() { + let out = generate_authz_overlays("Doc", &[authz("Edit", &["owner"], Some("creator"))]); + assert_eq!(out.matches("forbid(").count(), 2); + } + + #[test] + fn identifiers_are_escaped_against_injection() { + // A malicious action name trying to close the string and inject a permit. + let evil = "X\") };\npermit(principal, action, resource);\n//"; + let out = generate_authz_overlays("Doc", &[authz(evil, &["owner"], None)]); + // The injected permit text must not appear as a live clause — the quote + // is escaped, so it stays inside the Action string literal. + assert!(!out.contains("\npermit(principal, action, resource);")); + assert!(out.contains("\\\"")); + } + + #[test] + fn invalid_entity_type_emits_nothing() { + let out = generate_authz_overlays("Bad Name", &[authz("Publish", &["owner"], None)]); + assert!(out.is_empty()); + } } diff --git a/crates/temper-platform/src/os_apps/mod.rs b/crates/temper-platform/src/os_apps/mod.rs index b1e0b78ad..703aa17c5 100644 --- a/crates/temper-platform/src/os_apps/mod.rs +++ b/crates/temper-platform/src/os_apps/mod.rs @@ -324,6 +324,23 @@ fn app_relative_path(app_dir: &Path, path: &Path) -> String { .replace('\\', "/") } +/// Compile an automaton's spec-declared authorization (`requires_role` / +/// `requires`) into Cedar `forbid … unless` overlays (RFC-0002, ARN-255). +/// Returns `""` when no action carries a requirement. +fn spec_authz_overlay(entity_type: &str, automaton: &temper_spec::automaton::Automaton) -> String { + let actions: Vec = automaton + .actions + .iter() + .filter(|a| !a.requires_role.is_empty() || a.requires.is_some()) + .map(|a| temper_authz::ActionAuthz { + name: a.name.clone(), + requires_role: a.requires_role.clone(), + requires: a.requires.clone(), + }) + .collect(); + temper_authz::generate_authz_overlays(entity_type, &actions) +} + fn effective_app_deployment_mode(manifest: &AppManifest) -> AppDeploymentMode { let app_key = manifest .name @@ -942,11 +959,23 @@ fn load_app_bundle(app_dir: &Path) -> Option { let ioa_files = find_ioa_files(app_dir); // Read IOA specs, extracting entity type from the parsed automaton name. + // While each automaton is parsed, compile any spec-declared authorization + // (`requires_role` / `requires`) into Cedar `forbid … unless` overlays + // (RFC-0002, ARN-255) — collected alongside hand-written policies below. let mut specs = Vec::new(); + let mut generated_authz: Vec = Vec::new(); for (_hint, path) in &ioa_files { let source = std::fs::read_to_string(path).ok()?; let parsed = automaton::parse_automaton(&source).ok()?; - specs.push((parsed.automaton.name, source)); + let entity = parsed.automaton.name.clone(); + let overlay = spec_authz_overlay(&entity, &parsed); + if !overlay.is_empty() { + generated_authz.push(CedarPolicySource { + relative_path: format!("policies/_generated/{entity}.cedar"), + text: overlay, + }); + } + specs.push((entity, source)); } // Read CSDL (optional — apps without specs won't have CSDL). @@ -959,7 +988,7 @@ fn load_app_bundle(app_dir: &Path) -> Option { if deployment_mode == AppDeploymentMode::Commons { cedar_policy_files.extend(find_commons_cedar_policies(app_dir)); } - let cedar_policy_sources: Vec = cedar_policy_files + let mut cedar_policy_sources: Vec = cedar_policy_files .into_iter() .filter_map(|path| { let text = std::fs::read_to_string(&path).ok()?; @@ -969,6 +998,10 @@ fn load_app_bundle(app_dir: &Path) -> Option { }) }) .collect(); + // Generated authorization overlays compose with (append after) any + // hand-written policies, so a spec annotation restricts even an entity + // whose hand-written policy is permit-all. + cedar_policy_sources.extend(generated_authz); let cedar_policies: Vec = cedar_policy_sources .iter() .map(|source| source.text.clone()) diff --git a/crates/temper-platform/src/os_apps/mod_test.rs b/crates/temper-platform/src/os_apps/mod_test.rs index 42f887e25..c45c67d20 100644 --- a/crates/temper-platform/src/os_apps/mod_test.rs +++ b/crates/temper-platform/src/os_apps/mod_test.rs @@ -3422,3 +3422,69 @@ async fn test_local_stream_uploads_create_real_file_version_lineage() { let _ = fs::remove_file(format!("{db_path}-wal")); let _ = fs::remove_file(format!("{db_path}-shm")); } + +#[test] +fn spec_authz_overlay_compiles_action_annotations_to_forbid_overlays() { + // A minimal spec whose actions carry spec-declared authorization. + let ioa_source = r#" +[automaton] +name = "DesignLanguage" +states = ["Draft", "Published"] +initial = "Draft" + +[[state]] +name = "creator_sub" +type = "string" +initial = "" + +[[action]] +name = "Publish" +kind = "input" +from = ["Draft"] +to = "Published" +requires_role = ["owner", "curator"] + +[[action]] +name = "Withdraw" +kind = "input" +from = ["Published"] +to = "Draft" +requires = "creator" + +[[action]] +name = "SetCreator" +kind = "input" +from = ["Draft"] +params = ["creator_sub"] +"#; + + let parsed = automaton::parse_automaton(ioa_source).expect("spec parses"); + let overlay = spec_authz_overlay("DesignLanguage", &parsed); + + // Publish → role overlay; Withdraw → creator overlay; SetCreator → nothing. + assert!(overlay.contains("action == Action::\"Publish\"")); + assert!(overlay.contains("[\"owner\", \"curator\"].contains(principal.role)")); + assert!(overlay.contains("action == Action::\"Withdraw\"")); + assert!(overlay.contains("resource.creator_sub == principal.id")); + assert!(!overlay.contains("SetCreator")); + // Overlays are forbids that compose with the app's permit-all base. + assert_eq!(overlay.matches("forbid(").count(), 2); + assert!(!overlay.contains("permit(")); +} + +#[test] +fn spec_authz_overlay_empty_without_annotations() { + let ioa_source = r#" +[automaton] +name = "Plain" +states = ["A"] +initial = "A" + +[[action]] +name = "Touch" +kind = "input" +from = ["A"] +"#; + let parsed = automaton::parse_automaton(ioa_source).expect("spec parses"); + assert!(spec_authz_overlay("Plain", &parsed).is_empty()); +} diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index d1511ed96..a96e3a8b4 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -230,6 +230,8 @@ impl ParseState { }, "guard" => parse_guard_value(value, &mut action.guard)?, "effect" => parse_effect_value(value, &mut action.effect)?, + "requires_role" => action.requires_role = parse_string_array(value), + "requires" => action.requires = Some(value.to_string()), _ => {} } @@ -353,6 +355,8 @@ impl ParseState { triggers: Vec::new(), cedar_gate: None, sub_writes: Vec::new(), + requires_role: Vec::new(), + requires: None, }); self.current_section = Section::Action; true diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 34cf56049..86f01a246 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -226,6 +226,18 @@ pub struct Action { /// Declared sub-write contract for Composite actions (ADR-0040). #[serde(default, rename = "sub_writes")] pub sub_writes: Vec, + /// Spec-declared authorization: the principal roles allowed to invoke this + /// action (e.g. `["owner", "curator"]`). Compiled to a Cedar `forbid … + /// unless principal.role in […]` overlay at install (RFC-0002, ARN-255). + /// Empty = no role restriction. + #[serde(default)] + pub requires_role: Vec, + /// Spec-declared authorization: `"creator"` restricts this action to the + /// principal who owns the resource (`resource.creator_sub == principal.id`). + /// Compiled to a Cedar `forbid … unless` overlay at install. None = no + /// ownership restriction. See RFC-0002. + #[serde(default)] + pub requires: Option, } fn default_internal() -> String { From c87aff673dfcb4cadbb4de37c656c76357ad1886 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:46:42 -0400 Subject: [PATCH 06/11] fix(authz): govern the god-mode identity entities + close sign-out fail-open (review P1s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the fresh-context reviews of ARN-255: - CRITICAL: TrustedIssuer + PrincipalGeneration were HTTP-exposed with mutating actions and no Cedar gate — an agent could RegisterIssuer with its own JWKS and mint owner tokens (full authz takeover), or BumpGeneration to sign out arbitrary users. Add system-platform forbid overlays gating RegisterIssuer/ RotateIssuerKeys/Suspend/Resume/Revoke/BumpGeneration to System||Admin, and make permissive() ALSO merge the system-platform policy so the gate holds even under the ARN-230 permit-all fail-open. Platform seeding (env bootstrap + tests) now dispatches as System. - Sign-out fail-open: the generation check ran only when the token carried an auth_generation claim, so a claim-less token was permanently revocation-exempt. Treat a missing claim as generation 0 and always compare — a single BumpGeneration now invalidates it. New engine test proves agent/customer are forbidden from the registry even on a permit-all base; 75 authz + 8 integration + 19 identity tests green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125RD5byAmmMuvhLaK2jsZR --- crates/temper-authz/src/engine/mod.rs | 23 ++++- crates/temper-authz/src/engine/tests.rs | 86 +++++++++++++++++++ crates/temper-platform/src/bootstrap.rs | 4 +- .../tests/trusted_issuer_resolve.rs | 6 +- crates/temper-server/src/identity/resolver.rs | 18 ++-- 5 files changed, 124 insertions(+), 13 deletions(-) diff --git a/crates/temper-authz/src/engine/mod.rs b/crates/temper-authz/src/engine/mod.rs index f2caf62cb..f5920772f 100644 --- a/crates/temper-authz/src/engine/mod.rs +++ b/crates/temper-authz/src/engine/mod.rs @@ -133,8 +133,13 @@ impl AuthzEngine { /// so that Cedar evaluates to Allow for every principal kind (System or /// otherwise). Used in tests and permissive dev environments. pub fn permissive() -> Self { - let policy_set = + let mut policy_set = PolicySet::from_str("permit(principal, action, resource);").unwrap_or_default(); + // Even a permit-all fallback (e.g. the ARN-230 fail-open path) must keep + // the system-platform forbids — the god-mode identity entities + // (TrustedIssuer / PrincipalGeneration) stay System/Admin-only, so a + // fail-open tenant can never become an authz-takeover (ARN-255). + merge_system_platform_policy(&mut policy_set); Self { tenant_policies: RwLock::new(BTreeMap::new()), fallback_policy_set: RwLock::new(CompiledPolicies::new(policy_set)), @@ -680,6 +685,22 @@ impl AuthzEngine { const SYSTEM_PLATFORM_POLICY: &str = r#" @id("system-platform:broad-permit") permit(principal is System, action, resource); + +@id("system-platform:protect-trusted-issuer") +forbid( + principal, + action in [Action::"RegisterIssuer", Action::"RotateIssuerKeys", Action::"SuspendIssuer", Action::"ResumeIssuer", Action::"RevokeIssuer"], + resource is TrustedIssuer +) +unless { principal is System || principal is Admin }; + +@id("system-platform:protect-principal-generation") +forbid( + principal, + action == Action::"BumpGeneration", + resource is PrincipalGeneration +) +unless { principal is System || principal is Admin }; "#; /// PolicyId prefix used for the built-in system-platform policies diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index 5594a7e9e..e949bd409 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -875,3 +875,89 @@ fn generated_creator_overlay_enforces_ownership() { "a non-creator must be forbidden from Withdraw" ); } + +// --- Platform gate on the god-mode identity entities (ARN-255) --------------- + +fn agent_context(id: &str) -> SecurityContext { + SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), id.to_string()), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ]) +} + +/// RegisterIssuer / BumpGeneration must be System/Admin-only even when the +/// tenant base is permit-all (the ARN-230 fail-open scenario) — the +/// system-platform forbid overrides it. This is the fix for the "an agent +/// registers its own signing key → mints owner tokens" takeover. +#[test] +fn issuer_registry_is_admin_system_only_even_on_permit_all() { + let engine = AuthzEngine::permissive(); // permit(principal, action, resource) + system-platform + let attrs = HashMap::new(); + + // Allowed: System (platform) and Admin (operator key / the AS). + assert!( + engine + .authorize( + &SecurityContext::system(), + "RegisterIssuer", + "TrustedIssuer", + &attrs + ) + .is_allowed() + ); + assert!( + engine + .authorize(&admin_context(), "RegisterIssuer", "TrustedIssuer", &attrs) + .is_allowed() + ); + + // Forbidden: a verified agent or human — the takeover path. + for action in ["RegisterIssuer", "RotateIssuerKeys", "RevokeIssuer"] { + assert!( + !engine + .authorize( + &agent_context("kc_attacker"), + action, + "TrustedIssuer", + &attrs + ) + .is_allowed(), + "agent must be forbidden from {action} on TrustedIssuer" + ); + assert!( + !engine + .authorize( + &customer_context("human-x"), + action, + "TrustedIssuer", + &attrs + ) + .is_allowed(), + "customer must be forbidden from {action} on TrustedIssuer" + ); + } + + // BumpGeneration (per-user sign-out DoS) is likewise gated. + assert!( + engine + .authorize( + &admin_context(), + "BumpGeneration", + "PrincipalGeneration", + &attrs + ) + .is_allowed(), + "the AS (Admin) must be able to BumpGeneration" + ); + assert!( + !engine + .authorize( + &agent_context("kc_attacker"), + "BumpGeneration", + "PrincipalGeneration", + &attrs + ) + .is_allowed(), + "an agent must not be able to sign out arbitrary users" + ); +} diff --git a/crates/temper-platform/src/bootstrap.rs b/crates/temper-platform/src/bootstrap.rs index 89d7445e8..fce0a4a50 100644 --- a/crates/temper-platform/src/bootstrap.rs +++ b/crates/temper-platform/src/bootstrap.rs @@ -495,7 +495,9 @@ pub async fn bootstrap_trusted_issuer_from_env(state: &PlatformState, tenant: &s }; let tenant_id = temper_runtime::tenant::TenantId::new(tenant); - let agent_ctx = temper_server::request_context::AgentContext::for_service("platform-bootstrap"); + // Registering a trusted issuer is System-only (system-platform Cedar policy); + // the platform seeding itself acts as System. + let agent_ctx = temper_server::request_context::AgentContext::system(); let algorithms = std::env::var("TEMPER_TRUSTED_ISSUER_ALGS").unwrap_or_else(|_| "ES256".to_string()); diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs index 6d2d20cfa..d7598c2c5 100644 --- a/crates/temper-platform/tests/trusted_issuer_resolve.rs +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -71,7 +71,7 @@ async fn state_with_issuer(sk: &SigningKey) -> PlatformState { bootstrap_agent_specs(&state, "default", false, &cache); let tenant = TenantId::new("default"); - let ctx = AgentContext::for_service("trusted-issuer-e2e"); + let ctx = AgentContext::system(); state .server .dispatch_tenant_action( @@ -205,7 +205,7 @@ async fn suspended_issuer_stops_resolving() { ); // Suspend the issuer. - let ctx = AgentContext::for_service("trusted-issuer-e2e"); + let ctx = AgentContext::system(); state .server .dispatch_tenant_action( @@ -282,7 +282,7 @@ async fn bumping_generation_invalidates_older_tokens() { // Sign out everywhere: bump the human's generation to 1. The generation is // keyed on the human `sub`, which contributor_claims sets to "human-e2e". - let ctx = AgentContext::for_service("signout-e2e"); + let ctx = AgentContext::system(); state .server .dispatch_tenant_action( diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 919e548e8..5c4206910 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -221,14 +221,16 @@ impl IdentityResolver { .ok()?; // Sign-out-everywhere: reject a token whose generation is older than the - // principal's current generation (RFC-0002, ARN-255 option A). The - // generation is keyed on the human `sub`, so signing out a human also - // invalidates the tokens of agents acting for them. - if let Some(token_gen) = claims.auth_generation - && let Some(sub) = claims.sub.as_deref() - && token_gen < self.current_generation(state, tenant, sub).await - { - return None; + // principal's current generation (RFC-0002, ARN-255 option A). Keyed on + // the human `sub`, so signing out a human also invalidates the tokens of + // agents acting for them. A token that omits the claim is treated as + // generation 0 — revocation must NOT be skippable by an issuer that fails + // to stamp it, so a single BumpGeneration still invalidates it. + if let Some(sub) = claims.sub.as_deref() { + let token_gen = claims.auth_generation.unwrap_or(0); + if token_gen < self.current_generation(state, tenant, sub).await { + return None; + } } // Map verified claims → identity. A token with an `agent_type` is an From c5b70a46d54fc2434e58290be6587c1476857257 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:42:36 -0400 Subject: [PATCH 07/11] refactor(authz): back out the TOML authz compiler; enforce grant revocation in the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision (Rita, 2026-07-25): option B — spec-declared authorization is removed. The spec format is moving off TOML soon, so TOML-coupled authz sugar would be rewritten at that migration; hand-written .cedar is format-independent and stays the single place authorization is written. Reviews also found the annotation contract was wrong in both directions (requires_role forbids role-less service principals, so it could not express even the RFC's own Publish example; requires="creator" compared principal.id, which for an agent is its client_id, so an agent could never act for its owner). Authorization-in-spec is deferred to the design of the new spec language, where it can be expressive rather than sugar. The removed code remains in this branch's history. Backed out: Action.requires_role/requires (spec + parser), generate_authz_overlays and its escaping helpers, the os-app install generation seam, and their tests. Kept and extended — the identity core, which is independent of the compiler: - Grant liveness at the kernel (review P1): a revoked agent grant kept full OData access until its token expired, because only the MCP front door checked it. The resolver now rejects a token whose grant_id has been bumped, so revocation takes effect at the kernel. New integration test covers it. 9 integration + 75 authz + spec/platform suites green. --- crates/temper-authz/src/engine/tests.rs | 115 ------------- crates/temper-authz/src/lib.rs | 4 +- crates/temper-authz/src/policy_gen.rs | 158 ------------------ crates/temper-platform/src/os_apps/mod.rs | 37 +--- .../temper-platform/src/os_apps/mod_test.rs | 66 -------- .../tests/trusted_issuer_resolve.rs | 43 +++++ crates/temper-server/src/identity/resolver.rs | 11 ++ .../src/automaton/toml_parser/mod.rs | 4 - crates/temper-spec/src/automaton/types.rs | 12 -- 9 files changed, 58 insertions(+), 392 deletions(-) diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index e949bd409..d1ac0193d 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -761,121 +761,6 @@ fn candidate_filter_preserves_named_forbid_policy_ids() { ); } -// --- Spec-declared authorization overlays actually enforce (ARN-255) --------- - -fn role_context(id: &str, role: &str) -> SecurityContext { - SecurityContext::from_headers(&[ - ("X-Temper-Principal-Id".to_string(), id.to_string()), - ( - "X-Temper-Principal-Kind".to_string(), - "customer".to_string(), - ), - ("X-Temper-Agent-Role".to_string(), role.to_string()), - ]) -} - -/// A generated `forbid … unless role` overlay must restrict even on top of an -/// app's blanket permit-all base (Cedar forbid-overrides-permit). -#[test] -fn generated_role_overlay_enforces_on_permit_all_base() { - let base = "permit(principal, action, resource is DesignLanguage);"; - let overlay = crate::policy_gen::generate_authz_overlays( - "DesignLanguage", - &[crate::policy_gen::ActionAuthz { - name: "Publish".to_string(), - requires_role: vec!["owner".to_string(), "curator".to_string()], - requires: None, - }], - ); - let engine = AuthzEngine::new(&format!("{base}\n{overlay}")).expect("policy parses"); - let attrs = HashMap::new(); - - // Owner and curator may Publish; contributor and role-less may not. - assert!( - engine - .authorize( - &role_context("u1", "owner"), - "Publish", - "DesignLanguage", - &attrs - ) - .is_allowed() - ); - assert!( - engine - .authorize( - &role_context("u2", "curator"), - "Publish", - "DesignLanguage", - &attrs - ) - .is_allowed() - ); - assert!( - !engine - .authorize( - &role_context("u3", "contributor"), - "Publish", - "DesignLanguage", - &attrs - ) - .is_allowed(), - "contributor must be forbidden from Publish" - ); - assert!( - !engine - .authorize(&customer_context("u4"), "Publish", "DesignLanguage", &attrs) - .is_allowed(), - "a role-less principal must be forbidden from Publish" - ); - - // A different action on the same resource is unaffected by the overlay. - assert!( - engine - .authorize( - &customer_context("u5"), - "SetCreator", - "DesignLanguage", - &attrs - ) - .is_allowed(), - "unannotated actions stay allowed by the permit-all base" - ); -} - -/// A generated `requires = "creator"` overlay must restrict to the resource -/// owner (`resource.creator_sub == principal.id`). -#[test] -fn generated_creator_overlay_enforces_ownership() { - let base = "permit(principal, action, resource is Remix);"; - let overlay = crate::policy_gen::generate_authz_overlays( - "Remix", - &[crate::policy_gen::ActionAuthz { - name: "Withdraw".to_string(), - requires_role: vec![], - requires: Some("creator".to_string()), - }], - ); - let engine = AuthzEngine::new(&format!("{base}\n{overlay}")).expect("policy parses"); - - let mut owned_by_u1 = HashMap::new(); - owned_by_u1.insert("creator_sub".to_string(), serde_json::json!("u1")); - - // The owner may Withdraw; a different principal may not. - assert!( - engine - .authorize(&customer_context("u1"), "Withdraw", "Remix", &owned_by_u1) - .is_allowed(), - "the creator may Withdraw their own resource" - ); - assert!( - !engine - .authorize(&customer_context("u2"), "Withdraw", "Remix", &owned_by_u1) - .is_allowed(), - "a non-creator must be forbidden from Withdraw" - ); -} - // --- Platform gate on the god-mode identity entities (ARN-255) --------------- fn agent_context(id: &str) -> SecurityContext { diff --git a/crates/temper-authz/src/lib.rs b/crates/temper-authz/src/lib.rs index f504e998f..3fc2fb33f 100644 --- a/crates/temper-authz/src/lib.rs +++ b/crates/temper-authz/src/lib.rs @@ -15,6 +15,6 @@ pub use engine::{AuthzDecision, AuthzEngine}; pub use error::{AuthzDenial, AuthzError}; pub use metrics::init_metrics; pub use policy_gen::{ - ActionAuthz, ActionScope, DurationScope, PolicyScopeMatrix, PrincipalScope, ResourceScope, - generate_authz_overlays, generate_cedar_from_matrix, validate_policy_scope_matrix, + ActionScope, DurationScope, PolicyScopeMatrix, PrincipalScope, ResourceScope, + generate_cedar_from_matrix, validate_policy_scope_matrix, }; diff --git a/crates/temper-authz/src/policy_gen.rs b/crates/temper-authz/src/policy_gen.rs index f070ce350..5e4e531f0 100644 --- a/crates/temper-authz/src/policy_gen.rs +++ b/crates/temper-authz/src/policy_gen.rs @@ -223,103 +223,6 @@ pub fn generate_cedar_from_matrix( ) } -// --- Spec-declared authorization (RFC-0002, ARN-255) ------------------------ - -/// One action's declared authorization requirements, extracted from its IOA -/// spec. Kept as primitives so this module needs no dependency on temper-spec. -#[derive(Debug, Clone)] -pub struct ActionAuthz { - /// Action name (e.g. "Publish"). - pub name: String, - /// Roles allowed to invoke it (`requires_role`); empty = no role gate. - pub requires_role: Vec, - /// `"creator"` restricts to the resource owner; None = no ownership gate. - pub requires: Option, -} - -/// Escape a string for use inside a Cedar double-quoted string literal. -/// -/// App-authored identifiers (action names, role values) flow into generated -/// policy text; without escaping a `"` or `\` would break the policy or inject -/// clauses (the ARN-172 class). Cedar string escapes are backslash-based. -fn cedar_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\0' => out.push_str("\\0"), - _ => out.push(c), - } - } - out -} - -/// A Cedar entity-type name is an unquoted identifier, not a string literal, so -/// it cannot be escaped — it must already be a valid identifier. Reject -/// anything else rather than emit broken/injectable policy text. -fn is_valid_cedar_ident(s: &str) -> bool { - let mut chars = s.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() || c == '_' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Compile an entity's action authorization annotations into Cedar -/// `forbid … unless` overlays. -/// -/// A **forbid** overlay is used (not a permit) so the requirement composes on -/// top of an app's existing broad `permit(principal, action, resource is X)` -/// base: Cedar's forbid-overrides-permit semantics mean the overlay restricts -/// even when a blanket permit exists. Both requirements on one action emit two -/// forbids (role AND ownership must both hold to invoke). -/// -/// Returns `""` when no action carries a requirement, or when the entity type -/// is not a valid Cedar identifier (defensive — never emit broken policy text). -pub fn generate_authz_overlays(entity_type: &str, actions: &[ActionAuthz]) -> String { - if !is_valid_cedar_ident(entity_type) { - return String::new(); - } - let mut out = String::new(); - for a in actions { - let action_lit = cedar_escape(&a.name); - // The comment sits after `//`; strip line breaks so a newline in an - // app-authored name cannot break out of the comment and inject a clause. - let comment_name = comment_safe(&a.name); - - if !a.requires_role.is_empty() { - let roles = a - .requires_role - .iter() - .map(|r| format!("\"{}\"", cedar_escape(r))) - .collect::>() - .join(", "); - out.push_str(&format!( - "// generated from spec: {entity_type}.{comment_name} requires_role\n\ - forbid(\n principal,\n action == Action::\"{action_lit}\",\n resource is {entity_type}\n)\nunless {{ principal has role && [{roles}].contains(principal.role) }};\n\n", - )); - } - - if a.requires.as_deref() == Some("creator") { - out.push_str(&format!( - "// generated from spec: {entity_type}.{comment_name} requires creator\n\ - forbid(\n principal,\n action == Action::\"{action_lit}\",\n resource is {entity_type}\n)\nunless {{ resource has creator_sub && resource.creator_sub == principal.id }};\n\n", - )); - } - } - out -} - -/// Collapse line breaks to spaces so a value is safe inside a `//` comment. -fn comment_safe(s: &str) -> String { - s.replace(['\n', '\r'], " ") -} - #[cfg(test)] mod tests { use super::*; @@ -512,65 +415,4 @@ mod tests { }; assert!(validate_policy_scope_matrix(&m).is_err()); } - - // --- Spec-declared authorization overlays ------------------------------- - - fn authz(name: &str, roles: &[&str], requires: Option<&str>) -> ActionAuthz { - ActionAuthz { - name: name.to_string(), - requires_role: roles.iter().map(|s| s.to_string()).collect(), - requires: requires.map(|s| s.to_string()), - } - } - - #[test] - fn no_annotations_emits_nothing() { - let out = generate_authz_overlays("DesignLanguage", &[authz("Publish", &[], None)]); - assert!(out.is_empty()); - } - - #[test] - fn requires_role_emits_forbid_unless_role() { - let out = generate_authz_overlays( - "DesignLanguage", - &[authz("Publish", &["owner", "curator"], None)], - ); - assert!(out.contains("forbid(")); - assert!(out.contains("action == Action::\"Publish\"")); - assert!(out.contains("resource is DesignLanguage")); - assert!(out.contains("principal has role")); - assert!(out.contains("[\"owner\", \"curator\"].contains(principal.role)")); - // It is a forbid overlay, never a permit. - assert!(!out.contains("permit(")); - } - - #[test] - fn requires_creator_emits_ownership_forbid() { - let out = generate_authz_overlays("Remix", &[authz("Withdraw", &[], Some("creator"))]); - assert!(out.contains("resource has creator_sub && resource.creator_sub == principal.id")); - assert!(out.contains("action == Action::\"Withdraw\"")); - } - - #[test] - fn both_requirements_emit_two_forbids() { - let out = generate_authz_overlays("Doc", &[authz("Edit", &["owner"], Some("creator"))]); - assert_eq!(out.matches("forbid(").count(), 2); - } - - #[test] - fn identifiers_are_escaped_against_injection() { - // A malicious action name trying to close the string and inject a permit. - let evil = "X\") };\npermit(principal, action, resource);\n//"; - let out = generate_authz_overlays("Doc", &[authz(evil, &["owner"], None)]); - // The injected permit text must not appear as a live clause — the quote - // is escaped, so it stays inside the Action string literal. - assert!(!out.contains("\npermit(principal, action, resource);")); - assert!(out.contains("\\\"")); - } - - #[test] - fn invalid_entity_type_emits_nothing() { - let out = generate_authz_overlays("Bad Name", &[authz("Publish", &["owner"], None)]); - assert!(out.is_empty()); - } } diff --git a/crates/temper-platform/src/os_apps/mod.rs b/crates/temper-platform/src/os_apps/mod.rs index 703aa17c5..b1e0b78ad 100644 --- a/crates/temper-platform/src/os_apps/mod.rs +++ b/crates/temper-platform/src/os_apps/mod.rs @@ -324,23 +324,6 @@ fn app_relative_path(app_dir: &Path, path: &Path) -> String { .replace('\\', "/") } -/// Compile an automaton's spec-declared authorization (`requires_role` / -/// `requires`) into Cedar `forbid … unless` overlays (RFC-0002, ARN-255). -/// Returns `""` when no action carries a requirement. -fn spec_authz_overlay(entity_type: &str, automaton: &temper_spec::automaton::Automaton) -> String { - let actions: Vec = automaton - .actions - .iter() - .filter(|a| !a.requires_role.is_empty() || a.requires.is_some()) - .map(|a| temper_authz::ActionAuthz { - name: a.name.clone(), - requires_role: a.requires_role.clone(), - requires: a.requires.clone(), - }) - .collect(); - temper_authz::generate_authz_overlays(entity_type, &actions) -} - fn effective_app_deployment_mode(manifest: &AppManifest) -> AppDeploymentMode { let app_key = manifest .name @@ -959,23 +942,11 @@ fn load_app_bundle(app_dir: &Path) -> Option { let ioa_files = find_ioa_files(app_dir); // Read IOA specs, extracting entity type from the parsed automaton name. - // While each automaton is parsed, compile any spec-declared authorization - // (`requires_role` / `requires`) into Cedar `forbid … unless` overlays - // (RFC-0002, ARN-255) — collected alongside hand-written policies below. let mut specs = Vec::new(); - let mut generated_authz: Vec = Vec::new(); for (_hint, path) in &ioa_files { let source = std::fs::read_to_string(path).ok()?; let parsed = automaton::parse_automaton(&source).ok()?; - let entity = parsed.automaton.name.clone(); - let overlay = spec_authz_overlay(&entity, &parsed); - if !overlay.is_empty() { - generated_authz.push(CedarPolicySource { - relative_path: format!("policies/_generated/{entity}.cedar"), - text: overlay, - }); - } - specs.push((entity, source)); + specs.push((parsed.automaton.name, source)); } // Read CSDL (optional — apps without specs won't have CSDL). @@ -988,7 +959,7 @@ fn load_app_bundle(app_dir: &Path) -> Option { if deployment_mode == AppDeploymentMode::Commons { cedar_policy_files.extend(find_commons_cedar_policies(app_dir)); } - let mut cedar_policy_sources: Vec = cedar_policy_files + let cedar_policy_sources: Vec = cedar_policy_files .into_iter() .filter_map(|path| { let text = std::fs::read_to_string(&path).ok()?; @@ -998,10 +969,6 @@ fn load_app_bundle(app_dir: &Path) -> Option { }) }) .collect(); - // Generated authorization overlays compose with (append after) any - // hand-written policies, so a spec annotation restricts even an entity - // whose hand-written policy is permit-all. - cedar_policy_sources.extend(generated_authz); let cedar_policies: Vec = cedar_policy_sources .iter() .map(|source| source.text.clone()) diff --git a/crates/temper-platform/src/os_apps/mod_test.rs b/crates/temper-platform/src/os_apps/mod_test.rs index c45c67d20..42f887e25 100644 --- a/crates/temper-platform/src/os_apps/mod_test.rs +++ b/crates/temper-platform/src/os_apps/mod_test.rs @@ -3422,69 +3422,3 @@ async fn test_local_stream_uploads_create_real_file_version_lineage() { let _ = fs::remove_file(format!("{db_path}-wal")); let _ = fs::remove_file(format!("{db_path}-shm")); } - -#[test] -fn spec_authz_overlay_compiles_action_annotations_to_forbid_overlays() { - // A minimal spec whose actions carry spec-declared authorization. - let ioa_source = r#" -[automaton] -name = "DesignLanguage" -states = ["Draft", "Published"] -initial = "Draft" - -[[state]] -name = "creator_sub" -type = "string" -initial = "" - -[[action]] -name = "Publish" -kind = "input" -from = ["Draft"] -to = "Published" -requires_role = ["owner", "curator"] - -[[action]] -name = "Withdraw" -kind = "input" -from = ["Published"] -to = "Draft" -requires = "creator" - -[[action]] -name = "SetCreator" -kind = "input" -from = ["Draft"] -params = ["creator_sub"] -"#; - - let parsed = automaton::parse_automaton(ioa_source).expect("spec parses"); - let overlay = spec_authz_overlay("DesignLanguage", &parsed); - - // Publish → role overlay; Withdraw → creator overlay; SetCreator → nothing. - assert!(overlay.contains("action == Action::\"Publish\"")); - assert!(overlay.contains("[\"owner\", \"curator\"].contains(principal.role)")); - assert!(overlay.contains("action == Action::\"Withdraw\"")); - assert!(overlay.contains("resource.creator_sub == principal.id")); - assert!(!overlay.contains("SetCreator")); - // Overlays are forbids that compose with the app's permit-all base. - assert_eq!(overlay.matches("forbid(").count(), 2); - assert!(!overlay.contains("permit(")); -} - -#[test] -fn spec_authz_overlay_empty_without_annotations() { - let ioa_source = r#" -[automaton] -name = "Plain" -states = ["A"] -initial = "A" - -[[action]] -name = "Touch" -kind = "input" -from = ["A"] -"#; - let parsed = automaton::parse_automaton(ioa_source).expect("spec parses"); - assert!(spec_authz_overlay("Plain", &parsed).is_empty()); -} diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs index d7598c2c5..d29e4beba 100644 --- a/crates/temper-platform/tests/trusted_issuer_resolve.rs +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -314,3 +314,46 @@ async fn bumping_generation_invalidates_older_tokens() { "a token minted at the current generation must still resolve" ); } + +#[tokio::test] +async fn revoking_a_grant_stops_the_agents_token_at_the_kernel() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + let token = mint(&sk, header(), contributor_claims()); // grant_id = "grant-e2e" + + // Valid while the grant is live. + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some(), + "agent token resolves while its grant is live" + ); + + // Revoke the grant: bump the counter keyed by grant_id. + let ctx = AgentContext::system(); + state + .server + .dispatch_tenant_action( + &tenant, + "PrincipalGeneration", + "grant-e2e", + "BumpGeneration", + serde_json::json!({}), + &ctx, + ) + .await + .expect("revoke grant"); + + // A fresh resolver must now reject it — no waiting for token expiry. + let fresh = IdentityResolver::new(); + assert!( + fresh + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "a revoked grant must stop resolving at the kernel immediately" + ); +} diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 5c4206910..b28fb437f 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -233,6 +233,17 @@ impl IdentityResolver { } } + // Grant liveness: a revoked agent grant must stop working at the kernel, + // not just at the MCP front door — otherwise a revoked agent keeps full + // OData access until its token expires. The same monotonic counter is + // keyed by `grant_id`; revoking a grant bumps it, and ANY bump (> 0) + // means revoked, since a grant is never re-issued under the same id. + if let Some(grant_id) = claims.grant_id.as_deref().filter(|g| !g.is_empty()) + && self.current_generation(state, tenant, grant_id).await > 0 + { + return None; + } + // Map verified claims → identity. A token with an `agent_type` is an // agent acting for the human `sub`; a token with only a `sub` is the // human themselves (a Customer principal). diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index a96e3a8b4..d1511ed96 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -230,8 +230,6 @@ impl ParseState { }, "guard" => parse_guard_value(value, &mut action.guard)?, "effect" => parse_effect_value(value, &mut action.effect)?, - "requires_role" => action.requires_role = parse_string_array(value), - "requires" => action.requires = Some(value.to_string()), _ => {} } @@ -355,8 +353,6 @@ impl ParseState { triggers: Vec::new(), cedar_gate: None, sub_writes: Vec::new(), - requires_role: Vec::new(), - requires: None, }); self.current_section = Section::Action; true diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 86f01a246..34cf56049 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -226,18 +226,6 @@ pub struct Action { /// Declared sub-write contract for Composite actions (ADR-0040). #[serde(default, rename = "sub_writes")] pub sub_writes: Vec, - /// Spec-declared authorization: the principal roles allowed to invoke this - /// action (e.g. `["owner", "curator"]`). Compiled to a Cedar `forbid … - /// unless principal.role in […]` overlay at install (RFC-0002, ARN-255). - /// Empty = no role restriction. - #[serde(default)] - pub requires_role: Vec, - /// Spec-declared authorization: `"creator"` restricts this action to the - /// principal who owns the resource (`resource.creator_sub == principal.id`). - /// Compiled to a Cedar `forbid … unless` overlay at install. None = no - /// ownership restriction. See RFC-0002. - #[serde(default)] - pub requires: Option, } fn default_internal() -> String { From 13b6cb16c5e70c6d379631a810ca37d210de1c9f Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:06:28 -0400 Subject: [PATCH 08/11] test(authz): live end-to-end against a running server (ARN-255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots a real temper server, activates a trusted issuer through the same environment configuration a deployment uses, mints real ES256 tokens with the matching key, and drives the real HTTP surface. This became runnable once the issuer could be activated from the environment: registering one over HTTP is admin-gated, so a bare tenant refused it and the check could not get off the ground. 9/9 locally: valid token authenticates (200); rogue-key, expired, unregistered-issuer and garbage tokens rejected (401); the operator key still works, so the change is additive (200); and a verified agent token cannot register an issuer, rotate issuer keys, or bump a generation (403) — the takeover and sign-out-DoS paths are closed at the real HTTP surface, not only in unit tests. --- scripts/e2e-trusted-issuer.sh | 185 ++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 89 deletions(-) diff --git a/scripts/e2e-trusted-issuer.sh b/scripts/e2e-trusted-issuer.sh index 67446cec4..0b945dd81 100755 --- a/scripts/e2e-trusted-issuer.sh +++ b/scripts/e2e-trusted-issuer.sh @@ -1,122 +1,129 @@ #!/bin/bash -# Live local end-to-end check for TrustedIssuer JWT verification (ARN-255 step 1). +# Live end-to-end check for platform-issued token verification (ARN-255). # -# Boots a local temper server, registers a TrustedIssuer whose JWKS matches a -# locally generated P-256 key, mints ES256 tokens with that key, and proves: -# 1. a valid token resolves to a verified agent principal (request succeeds) -# 2. a token signed by an UNKNOWN key is rejected (401) -# 3. an expired token is rejected (401) -# 4. a token from an unregistered issuer is rejected (401) -# 5. opaque AgentCredential bearers still work (additive: nothing broke) +# Boots a real temper server, activates a trusted issuer through the same +# environment configuration a deployment uses, mints real ES256 tokens with the +# matching private key, and drives the real HTTP surface to prove: +# 1. a valid token authenticates (not 401) +# 2. a token signed by an unknown key is rejected (401) +# 3. an expired token is rejected (401) +# 4. a token from an unregistered issuer is rejected (401) +# 5. a garbage/tampered token is rejected (401) +# 6. the operator key still works — the change is additive (200) +# 7. a verified agent token CANNOT register an issuer (403) +# (the takeover path: register your own key, mint owner tokens) +# 8. a verified agent token CANNOT bump a generation (403) +# (per-user sign-out denial of service) # -# NOTE ON AUTHORIZATION: registering a TrustedIssuer is an admin-gated action, -# so this script needs the target tenant to carry a Cedar policy permitting -# admin management of TrustedIssuer (RegisterIssuer/Suspend/...). A bare -# `temper serve` tenant is default-deny with no seeded permits, so RegisterIssuer -# returns 403 there — as does AgentCredential.Issue; it is not specific to this -# entity. Point this script at a tenant provisioned with that policy. The -# resolver logic itself (verify -> principal, and rejection of bad tokens) is -# covered without that dependency by the integration test -# `crates/temper-platform/tests/trusted_issuer_resolve.rs`, which seeds the -# issuer through the internal dispatch path and asserts the same five outcomes. -# -# Requires: cargo (workspace built), python3 with 'cryptography', jq, curl. -# Usage: scripts/e2e-trusted-issuer.sh [port] (default 3467) -set -euo pipefail +# Requires: cargo, python3 with 'cryptography', curl. Usage: +# scripts/e2e-trusted-issuer.sh [port] +set -uo pipefail -PORT="${1:-3467}" +PORT="${1:-3477}" BASE="http://localhost:${PORT}" TENANT="default" -API_KEY="${TEMPER_API_KEY:-local-e2e-operator-key}" -WORKDIR="$(mktemp -d)" -trap 'kill "${SERVER_PID:-}" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT +API_KEY="local-e2e-operator-key" +ISSUER="https://e2e.issuer.local" +AUD="temper-e2e" +WORK="$(mktemp -d)" +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT -say() { printf '\n== %s\n' "$*" >&2; } +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } -# --- 1. Generate a P-256 keypair + JWKS + tokens (python, one shot) --------- -say "Generating P-256 keypair, JWKS, and test tokens" -python3 - "$WORKDIR" <<'PY' +say "Minting a P-256 key, its JWKS, and four test tokens" +python3 - "$WORK" "$ISSUER" "$AUD" <<'PY' import base64, json, sys, time from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from cryptography.hazmat.primitives import hashes -workdir = sys.argv[1] -def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() +work, issuer, aud = sys.argv[1], sys.argv[2], sys.argv[3] +b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode() -def mint(key, kid, claims): - header = {"alg": "ES256", "kid": kid, "typ": "JWT"} - signing_input = f"{b64u(json.dumps(header).encode())}.{b64u(json.dumps(claims).encode())}" - der = key.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256())) - r, s = decode_dss_signature(der) - sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") - return f"{signing_input}.{b64u(sig)}" +def mint(key, claims, kid="e2e-k1"): + head = {"alg": "ES256", "kid": kid, "typ": "JWT"} + si = f'{b64(json.dumps(head).encode())}.{b64(json.dumps(claims).encode())}' + r, s = decode_dss_signature(key.sign(si.encode(), ec.ECDSA(hashes.SHA256()))) + return f'{si}.{b64(r.to_bytes(32,"big") + s.to_bytes(32,"big"))}' key = ec.generate_private_key(ec.SECP256R1()) -pub = key.public_key().public_numbers() -jwks = {"keys": [{"kty": "EC", "crv": "P-256", "kid": "e2e-k1", - "x": b64u(pub.x.to_bytes(32, "big")), - "y": b64u(pub.y.to_bytes(32, "big"))}]} -open(f"{workdir}/jwks.json", "w").write(json.dumps(jwks)) +pn = key.public_key().public_numbers() +open(f"{work}/jwks.json","w").write(json.dumps({"keys":[{ + "kty":"EC","crv":"P-256","kid":"e2e-k1", + "x": b64(pn.x.to_bytes(32,"big")), "y": b64(pn.y.to_bytes(32,"big"))}]})) now = int(time.time()) -base = {"iss": "https://e2e.issuer.local", "aud": "temper-e2e", - "sub": "human-sub-e2e", "client_id": "kc_e2e_agent", "agent_type": "contributor", - "grant_id": "grant-e2e", "nbf": now - 60} -open(f"{workdir}/token_valid.txt", "w").write(mint(key, "e2e-k1", {**base, "exp": now + 900})) -open(f"{workdir}/token_expired.txt", "w").write(mint(key, "e2e-k1", {**base, "exp": now - 600})) -open(f"{workdir}/token_bad_iss.txt", "w").write( - mint(key, "e2e-k1", {**base, "iss": "https://unregistered.example", "exp": now + 900})) - -rogue = ec.generate_private_key(ec.SECP256R1()) -open(f"{workdir}/token_rogue.txt", "w").write(mint(rogue, "e2e-k1", {**base, "exp": now + 900})) -print("minted 4 tokens") +base = {"iss": issuer, "aud": aud, "sub": "human-e2e", "client_id": "kc_agent_e2e", + "agent_type": "contributor", "grant_id": "grant-e2e", "nbf": now - 300} +open(f"{work}/valid.txt","w").write(mint(key, {**base, "exp": now + 900})) +open(f"{work}/expired.txt","w").write(mint(key, {**base, "exp": now - 600})) +open(f"{work}/bad_iss.txt","w").write(mint(key, {**base, "iss": "https://unregistered.example", "exp": now + 900})) +open(f"{work}/rogue.txt","w").write(mint(ec.generate_private_key(ec.SECP256R1()), {**base, "exp": now + 900})) +print(" 4 tokens + JWKS ready") PY +[ -f "$WORK/valid.txt" ] || { echo "token minting failed"; exit 1; } -# --- 2. Boot the server ------------------------------------------------------ -say "Starting local temper server on :$PORT" -TEMPER_API_KEY="$API_KEY" cargo run -p temper-cli --bin temper -- serve --port "$PORT" --no-observe \ - >"$WORKDIR/server.log" 2>&1 & +say "Starting a real temper server on :$PORT with the issuer activated by env" +TEMPER_API_KEY="$API_KEY" \ +TEMPER_TRUSTED_ISSUER_URL="$ISSUER" \ +TEMPER_TRUSTED_ISSUER_JWKS="$(cat "$WORK/jwks.json")" \ +TEMPER_TRUSTED_ISSUER_AUD="$AUD" \ + cargo run -q -p temper-cli --bin temper -- serve --port "$PORT" --no-observe \ + >"$WORK/server.log" 2>&1 & SERVER_PID=$! -for i in $(seq 1 120); do +for _ in $(seq 1 150); do curl -sf "$BASE/healthz" >/dev/null 2>&1 && break sleep 2 - kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died; log tail:"; tail -40 "$WORKDIR/server.log"; exit 1; } + kill -0 "$SERVER_PID" 2>/dev/null || { echo "server died:"; tail -30 "$WORK/server.log"; exit 1; } done -curl -sf "$BASE/healthz" >/dev/null || { echo "server never became healthy"; tail -40 "$WORKDIR/server.log"; exit 1; } - -# --- 3. Register the TrustedIssuer (operator key) --------------------------- -say "Registering TrustedIssuer https://e2e.issuer.local" -ISSUER_ID="https%3A%2F%2Fe2e.issuer.local" -curl -sf -X POST \ - "$BASE/tdata/TrustedIssuers('$ISSUER_ID')/Temper.RegisterIssuer" \ - -H "Authorization: Bearer $API_KEY" -H "X-Tenant-Id: $TENANT" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --rawfile jwks "$WORKDIR/jwks.json" '{ - issuer: "https://e2e.issuer.local", jwks_json: $jwks, - audience: "temper-e2e", algorithms: "ES256", - description: "local e2e issuer", created_by: "e2e-script"}')" >/dev/null -echo "registered" +curl -sf "$BASE/healthz" >/dev/null || { echo "never healthy:"; tail -30 "$WORK/server.log"; exit 1; } +echo " healthy" +grep -q "Trusted issuer '$ISSUER' registered" "$WORK/server.log" \ + && echo " issuer registered from environment at boot" \ + || { echo " ISSUER NOT REGISTERED — see log"; tail -20 "$WORK/server.log"; } -probe() { # probe -> HTTP status of a governed read - curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer $1" -H "X-Tenant-Id: $TENANT" \ - "$BASE/tdata/TrustedIssuers('$ISSUER_ID')" +code() { # code [method] [path] [body] + local tok="$1" method="${2:-GET}" path="${3:-/tdata/TrustedIssuers}" body="${4:-}" + if [ -n "$body" ]; then + curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $tok" -H "X-Tenant-Id: $TENANT" \ + -H "Content-Type: application/json" -d "$body" + else + curl -s -o /dev/null -w '%{http_code}' -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $tok" -H "X-Tenant-Id: $TENANT" + fi } -# --- 4. The five checks ------------------------------------------------------ PASS=0; FAIL=0 -check() { # check - if [ "$2" = "$3" ]; then PASS=$((PASS+1)); echo "PASS $1 (HTTP $2)"; - else FAIL=$((FAIL+1)); echo "FAIL $1 (got HTTP $2, want $3)"; fi +check() { # check + local name="$1" got="$2"; shift 2 + for want in "$@"; do + if [ "$got" = "$want" ]; then printf ' \033[32mPASS\033[0m %s (HTTP %s)\n' "$name" "$got"; PASS=$((PASS+1)); return; fi + done + printf ' \033[31mFAIL\033[0m %s (got HTTP %s, wanted %s)\n' "$name" "$got" "$*"; FAIL=$((FAIL+1)) } -say "Running checks" -check "valid token accepted" "$(probe "$(cat "$WORKDIR/token_valid.txt")")" "200" -check "rogue-key token rejected" "$(probe "$(cat "$WORKDIR/token_rogue.txt")")" "401" -check "expired token rejected" "$(probe "$(cat "$WORKDIR/token_expired.txt")")" "401" -check "unregistered issuer rejected" "$(probe "$(cat "$WORKDIR/token_bad_iss.txt")")" "401" -check "operator key still works" "$(probe "$API_KEY")" "200" +VALID=$(cat "$WORK/valid.txt") +ISS_ENC="https%3A%2F%2Fe2e.issuer.local" + +say "Token verification" +check "valid token authenticates" "$(code "$VALID")" 200 403 404 +check "rogue-key token rejected" "$(code "$(cat "$WORK/rogue.txt")")" 401 +check "expired token rejected" "$(code "$(cat "$WORK/expired.txt")")" 401 +check "unregistered issuer rejected" "$(code "$(cat "$WORK/bad_iss.txt")")" 401 +check "garbage token rejected" "$(code 'not.a.jwt')" 401 +check "operator key still works (additive)" "$(code "$API_KEY")" 200 + +say "Privilege boundary on the identity entities" +REG_BODY='{"issuer":"https://attacker.example","jwks_json":"{\"keys\":[]}","audience":"x","algorithms":"ES256","description":"takeover attempt","created_by":"attacker"}' +check "agent token CANNOT register an issuer" \ + "$(code "$VALID" POST "/tdata/TrustedIssuers('https%3A%2F%2Fattacker.example')/Temper.RegisterIssuer" "$REG_BODY")" 403 +check "agent token CANNOT rotate issuer keys" \ + "$(code "$VALID" POST "/tdata/TrustedIssuers('$ISS_ENC')/Temper.RotateIssuerKeys" '{"jwks_json":"{\"keys\":[]}"}')" 403 +check "agent token CANNOT bump a generation" \ + "$(code "$VALID" POST "/tdata/PrincipalGenerations('human-e2e')/Temper.BumpGeneration" '{}')" 403 say "Result: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] From 8a846e360bcad3c9a1fc0a0c7a35537be45a0209 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:07:30 -0400 Subject: [PATCH 09/11] fix(authz): close the generic-CRUD bypass and unify principal construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reviews (Claude, Greptile, Codex) ran against this branch. Codex found two exploitable holes the others missed, both rooted in the same blind spot: every gate here named bound actions, while OData authorizes PATCH/PUT/DELETE as the lowercase `update`/`delete` actions. - The identity-entity gate listed RegisterIssuer/RotateIssuerKeys/... only, so a contributor could PATCH TrustedIssuer.jwks_json with action `update`, install its own signing key and mint owner tokens — the exact takeover the gate was written to stop, reachable through a different verb. Both forbids are now resource-wide (any action) unless System or Admin. The live e2e missed it because it too probed only bound actions; the kernel test now covers create/read/update/delete explicitly. - Verified JWT identity was only constructed for bound actions. The shared read/create/update/delete helper always used the agent-only constructor, turning a verified human into an Agent with an empty type and dropping role and acting_for — so a read and a bound action evaluated different principals for the same token. There is now one authoritative conversion, ResolvedIdentity::to_security_context, used by every entry point. Also from Greptile: - Generation reads treated every failure as "generation 0", so a revoked token was accepted during an actor timeout. A missing entity already materialises at 0, so Err now means deny: revocation checks fail closed. - The trusted issuer was registered for tenant `default` only, while agent specs install for every tenant; issuer-signed tokens would 401 elsewhere. It now registers for each tenant that receives the agent specs. 67 authz + 9 integration + 19 identity tests green; clippy -D warnings clean. --- crates/temper-authz/src/engine/mod.rs | 12 +-- crates/temper-authz/src/engine/tests.rs | 21 +++++ crates/temper-cli/src/serve/bootstrap.rs | 16 +++- crates/temper-server/src/identity/resolver.rs | 84 +++++++++++++++---- crates/temper-server/src/odata/authz.rs | 6 +- crates/temper-server/src/odata/bindings.rs | 31 +------ 6 files changed, 111 insertions(+), 59 deletions(-) diff --git a/crates/temper-authz/src/engine/mod.rs b/crates/temper-authz/src/engine/mod.rs index f5920772f..04f63a8cb 100644 --- a/crates/temper-authz/src/engine/mod.rs +++ b/crates/temper-authz/src/engine/mod.rs @@ -687,19 +687,11 @@ const SYSTEM_PLATFORM_POLICY: &str = r#" permit(principal is System, action, resource); @id("system-platform:protect-trusted-issuer") -forbid( - principal, - action in [Action::"RegisterIssuer", Action::"RotateIssuerKeys", Action::"SuspendIssuer", Action::"ResumeIssuer", Action::"RevokeIssuer"], - resource is TrustedIssuer -) +forbid(principal, action, resource is TrustedIssuer) unless { principal is System || principal is Admin }; @id("system-platform:protect-principal-generation") -forbid( - principal, - action == Action::"BumpGeneration", - resource is PrincipalGeneration -) +forbid(principal, action, resource is PrincipalGeneration) unless { principal is System || principal is Admin }; "#; diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index d1ac0193d..30d555cec 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -845,4 +845,25 @@ fn issuer_registry_is_admin_system_only_even_on_permit_all() { .is_allowed(), "an agent must not be able to sign out arbitrary users" ); + + // Generic OData CRUD must not walk around the named actions: PATCH is + // authorized as "update", so a gate listing only named actions would let an + // agent rewrite TrustedIssuer.jwks_json with its own key and mint owner + // tokens. The forbid is resource-wide for exactly that reason. + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["create", "read", "update", "delete"] { + assert!( + !engine + .authorize(&agent_context("kc_attacker"), action, entity, &attrs) + .is_allowed(), + "agent must not reach {entity} via generic {action}" + ); + assert!( + engine + .authorize(&admin_context(), action, entity, &attrs) + .is_allowed(), + "Admin must still {action} {entity}" + ); + } + } } diff --git a/crates/temper-cli/src/serve/bootstrap.rs b/crates/temper-cli/src/serve/bootstrap.rs index 2807c8e75..137d0e6a2 100644 --- a/crates/temper-cli/src/serve/bootstrap.rs +++ b/crates/temper-cli/src/serve/bootstrap.rs @@ -439,6 +439,13 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St temper_platform::persist_system_verification(&turso, &sys_hashes, &sys_cache).await; } + // Every tenant that receives the agent specs also needs the trusted issuer + // registered, or issuer-signed JWTs 401 there: bearer resolution looks the + // issuer up in the REQUEST's tenant, not in `default`. + let mut agent_spec_tenants: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + agent_spec_tenants.insert("default".to_string()); + let default_cache = load_verified_cache(state, "default").await; let default_hashes = temper_platform::bootstrap_agent_specs(state, "default", false, &default_cache); @@ -453,6 +460,7 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St } for (tenant, _dir) in apps { + agent_spec_tenants.insert(tenant.clone()); let cache = load_verified_cache(state, tenant).await; // App tenants already have user specs loaded in Phase 2; merge the // built-in agent OS entities so we do not replace their entity-set map. @@ -472,6 +480,7 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St .and_then(|stack| stack.turso.clone()) { for tenant in provider.connected_tenants().await { + agent_spec_tenants.insert(tenant.clone()); let cache = load_verified_cache(state, &tenant).await; let hashes = temper_platform::bootstrap_agent_specs(state, &tenant, true, &cache); if let Some(turso) = state.server.turso_store_for_tenant(&tenant).await { @@ -489,8 +498,11 @@ pub(super) async fn bootstrap_tenants(state: &PlatformState, apps: &[(String, St // Register a trusted JWT issuer from env config, if provided (ARN-255). // This is how a deployment activates the platform-issued-token path without - // an authenticated API call. - temper_platform::bootstrap_trusted_issuer_from_env(state, "default").await; + // an authenticated API call. Registered for every tenant that carries the + // agent specs, since the issuer is resolved in the request's own tenant. + for tenant in &agent_spec_tenants { + temper_platform::bootstrap_trusted_issuer_from_env(state, tenant).await; + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index b28fb437f..233f9858c 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -9,6 +9,7 @@ use std::sync::{Arc, RwLock}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use temper_authz::{PrincipalKind, SecurityContext}; use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; @@ -58,6 +59,40 @@ pub struct ResolvedIdentity { pub role: Option, } +impl ResolvedIdentity { + /// The one authoritative conversion from a resolved identity to the Cedar + /// principal, used by EVERY external entry point (bound actions, reads, + /// create, update, delete, streams, rate limiting). + /// + /// Keeping this in one place matters: constructing it ad hoc let the CRUD + /// paths fall back to the agent-only constructor, which turned a verified + /// human into an Agent with an empty type and silently dropped `role` and + /// `acting_for` — so a read and a bound action evaluated different + /// principals for the same token. + pub fn to_security_context(&self, session_id: Option<&str>) -> SecurityContext { + if !self.from_jwt { + return SecurityContext::from_resolved_identity( + &self.agent_instance_id, + &self.agent_type_name, + session_id, + ); + } + let (kind, agent_type) = if self.is_human { + (PrincipalKind::Customer, None) + } else { + (PrincipalKind::Agent, Some(self.agent_type_name.as_str())) + }; + SecurityContext::from_verified_jwt( + &self.agent_instance_id, + kind, + agent_type, + self.acting_for.as_deref(), + self.role.as_deref(), + session_id, + ) + } +} + /// Cached resolution result with expiry. struct CacheEntry { identity: ResolvedIdentity, @@ -228,7 +263,8 @@ impl IdentityResolver { // to stamp it, so a single BumpGeneration still invalidates it. if let Some(sub) = claims.sub.as_deref() { let token_gen = claims.auth_generation.unwrap_or(0); - if token_gen < self.current_generation(state, tenant, sub).await { + // `?` denies when the read failed — see current_generation. + if token_gen < self.current_generation(state, tenant, sub).await? { return None; } } @@ -239,7 +275,7 @@ impl IdentityResolver { // keyed by `grant_id`; revoking a grant bumps it, and ANY bump (> 0) // means revoked, since a grant is never re-issued under the same id. if let Some(grant_id) = claims.grant_id.as_deref().filter(|g| !g.is_empty()) - && self.current_generation(state, tenant, grant_id).await > 0 + && self.current_generation(state, tenant, grant_id).await? > 0 { return None; } @@ -288,21 +324,41 @@ impl IdentityResolver { /// Read a principal's current sign-out-everywhere generation. /// - /// Missing entity ⇒ generation 0 (never signed out everywhere). The counter - /// lives in the kernel's `PrincipalGeneration` entity, so this check is - /// generic across apps. - async fn current_generation(&self, state: &ServerState, tenant: &TenantId, sub: &str) -> i64 { + /// `None` means the read failed and the caller must deny. A principal that + /// has never been revoked is not an error: the entity materialises on read + /// at generation 0, which is the correct baseline. Conflating the two would + /// make a revoked token acceptable during an actor timeout or a missing + /// transition table — a revocation check has to fail closed. + /// + /// The counter lives in the kernel's `PrincipalGeneration` entity, so this + /// check is generic across apps. + async fn current_generation( + &self, + state: &ServerState, + tenant: &TenantId, + key: &str, + ) -> Option { match state - .get_tenant_entity_state(tenant, "PrincipalGeneration", sub) + .get_tenant_entity_state(tenant, "PrincipalGeneration", key) .await { - Ok(resp) => resp - .state - .counters - .get("generation") - .map(|c| *c as i64) - .unwrap_or(0), - Err(_) => 0, + Ok(resp) => Some( + resp.state + .counters + .get("generation") + .map(|c| *c as i64) + .unwrap_or(0), + ), + Err(e) => { + tracing::warn!( + tenant = %tenant, + key, + error = %e, + "PrincipalGeneration read failed; denying the token rather than \ + treating it as never-revoked" + ); + None + } } } diff --git a/crates/temper-server/src/odata/authz.rs b/crates/temper-server/src/odata/authz.rs index c77018745..7dbcfb058 100644 --- a/crates/temper-server/src/odata/authz.rs +++ b/crates/temper-server/src/odata/authz.rs @@ -26,11 +26,7 @@ pub(super) fn request_security_context( resolved_identity: Option<&ResolvedIdentity>, ) -> SecurityContext { if let Some(identity) = resolved_identity { - SecurityContext::from_resolved_identity( - &identity.agent_instance_id, - &identity.agent_type_name, - agent_ctx.session_id.as_deref(), - ) + identity.to_security_context(agent_ctx.session_id.as_deref()) } else { security_context_from_headers(headers, None, agent_ctx.session_id.as_deref(), None) } diff --git a/crates/temper-server/src/odata/bindings.rs b/crates/temper-server/src/odata/bindings.rs index ec2156362..864a47bf5 100644 --- a/crates/temper-server/src/odata/bindings.rs +++ b/crates/temper-server/src/odata/bindings.rs @@ -8,8 +8,6 @@ use temper_runtime::scheduler::sim_now; use temper_runtime::tenant::TenantId; use tracing_opentelemetry::OpenTelemetrySpanExt; -use temper_authz::{PrincipalKind, SecurityContext}; - use super::account_verification::enforce_commons_account_verified_for_action; use super::common::run_write_prechecks; use super::rate_limit::{enforce_commons_write_rate_limit, owner_id_from_action}; @@ -83,32 +81,9 @@ pub(super) async fn dispatch_bound_action( "agent.type", identity.agent_type_name.clone(), )); - if identity.from_jwt { - // Trusted-issuer JWT. A human token yields a Customer principal; an - // agent token yields an Agent acting for the owning human (`sub`). - let (kind, agent_type) = if identity.is_human { - (PrincipalKind::Customer, None) - } else { - ( - PrincipalKind::Agent, - Some(identity.agent_type_name.as_str()), - ) - }; - SecurityContext::from_verified_jwt( - &identity.agent_instance_id, - kind, - agent_type, - identity.acting_for.as_deref(), - identity.role.as_deref(), - agent_ctx.session_id.as_deref(), - ) - } else { - SecurityContext::from_resolved_identity( - &identity.agent_instance_id, - &identity.agent_type_name, - agent_ctx.session_id.as_deref(), - ) - } + // One authoritative conversion, shared with the read/create/update/ + // delete paths — see ResolvedIdentity::to_security_context. + identity.to_security_context(agent_ctx.session_id.as_deref()) } else { // No credential resolved — operator/admin access via global API key. // Build SecurityContext from X-Temper-Principal-Kind header (admin/system) From 96f8e42000b1b47d835ce716f5ab03fba7fdf72a Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:25:18 -0400 Subject: [PATCH 10/11] fix(authz): unbreak the authorization server; stop tokens creating issuer rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P0s from the third independent review, both verified live against a real server with the app's own Cedar bundle loaded. 1. The previous commit's resource-wide forbid locked out the caller that has to do the writing. The authorization server reaches the kernel with the shared API key, which resolves through the bootstrapped operator credential to Agent::"operator" — neither System nor Admin — and no app policy grants these entities at all, so even the Admin branch hit default-deny. In production that meant: token issue and refresh 500, grant revocation left the kernel still honouring a revoked agent, sign-out-everywhere failed on its first statement, and (with the session check now failing closed) every user would have been signed out permanently. The system-platform policy now permits, and exempts from the forbid, the operator credential alongside System and Admin. This weakens nothing: a holder of the shared key can already self-declare admin at the ingress. Tested against a production- shaped policy set rather than the permissive engine, which is what hid it. 2. `iss` is attacker-chosen and read before any signature check, and entity reads spawn the entity — so presenting any junk token persisted an Active TrustedIssuer row with empty fields, unauthenticated, for any string the caller chose. That is storage growth, an audit surface reading as "Active trusted issuers", and one missing field away from a trust bypass. Both resolver lookups now check existence first and never materialise a row; an absent generation counter correctly reads as never-revoked. 11 integration + 68 authz tests green, including the two new negative proofs. --- crates/temper-authz/src/engine/mod.rs | 12 +++- crates/temper-authz/src/engine/tests.rs | 54 ++++++++++++++++++ .../tests/trusted_issuer_resolve.rs | 55 +++++++++++++++++++ crates/temper-server/src/identity/resolver.rs | 15 +++++ 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/crates/temper-authz/src/engine/mod.rs b/crates/temper-authz/src/engine/mod.rs index 04f63a8cb..fbf59e0b8 100644 --- a/crates/temper-authz/src/engine/mod.rs +++ b/crates/temper-authz/src/engine/mod.rs @@ -686,13 +686,21 @@ const SYSTEM_PLATFORM_POLICY: &str = r#" @id("system-platform:broad-permit") permit(principal is System, action, resource); +@id("system-platform:identity-entities-permit-trusted-issuer") +permit(principal, action, resource is TrustedIssuer) +when { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; + +@id("system-platform:identity-entities-permit-principal-generation") +permit(principal, action, resource is PrincipalGeneration) +when { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; + @id("system-platform:protect-trusted-issuer") forbid(principal, action, resource is TrustedIssuer) -unless { principal is System || principal is Admin }; +unless { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; @id("system-platform:protect-principal-generation") forbid(principal, action, resource is PrincipalGeneration) -unless { principal is System || principal is Admin }; +unless { principal is System || principal is Admin || (principal has agent_type && principal.agent_type == "operator") }; "#; /// PolicyId prefix used for the built-in system-platform policies diff --git a/crates/temper-authz/src/engine/tests.rs b/crates/temper-authz/src/engine/tests.rs index 30d555cec..9015f8f4a 100644 --- a/crates/temper-authz/src/engine/tests.rs +++ b/crates/temper-authz/src/engine/tests.rs @@ -774,6 +774,60 @@ fn agent_context(id: &str) -> SecurityContext { /// tenant base is permit-all (the ARN-230 fail-open scenario) — the /// system-platform forbid overrides it. This is the fix for the "an agent /// registers its own signing key → mints owner tokens" takeover. + +/// The platform's own operator credential (the shared API key, resolved through +/// the bootstrapped operator AgentType) must still manage these entities under a +/// PRODUCTION-shaped policy set — an app bundle that carries no permit for them +/// at all. Locking it out breaks token issue/refresh, grant revocation and +/// sign-out-everywhere, since the authorization server calls with exactly that +/// credential. This is not a weakening: a holder of the shared key can already +/// self-declare admin at the ingress. +#[test] +fn operator_credential_can_manage_identity_entities_under_app_policies() { + // An app bundle like katagami's: permits for its own entities, nothing for + // TrustedIssuer or PrincipalGeneration. + let engine = AuthzEngine::new("permit(principal, action, resource is DesignLanguage);") + .expect("policy parses"); + let attrs = HashMap::new(); + + let operator = SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), "operator".to_string()), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ("X-Temper-Agent-Type".to_string(), "operator".to_string()), + ]); + + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["read", "update", "RegisterIssuer", "BumpGeneration"] { + assert!( + engine + .authorize(&operator, action, entity, &attrs) + .is_allowed(), + "the operator credential must be able to {action} {entity}" + ); + } + } + + // A contributor agent is still shut out under the same policy set. + let contributor = SecurityContext::from_headers(&[ + ( + "X-Temper-Principal-Id".to_string(), + "kc_attacker".to_string(), + ), + ("X-Temper-Principal-Kind".to_string(), "agent".to_string()), + ("X-Temper-Agent-Type".to_string(), "contributor".to_string()), + ]); + for entity in ["TrustedIssuer", "PrincipalGeneration"] { + for action in ["read", "update", "RegisterIssuer", "BumpGeneration"] { + assert!( + !engine + .authorize(&contributor, action, entity, &attrs) + .is_allowed(), + "a contributor must not {action} {entity}" + ); + } + } +} + #[test] fn issuer_registry_is_admin_system_only_even_on_permit_all() { let engine = AuthzEngine::permissive(); // permit(principal, action, resource) + system-platform diff --git a/crates/temper-platform/tests/trusted_issuer_resolve.rs b/crates/temper-platform/tests/trusted_issuer_resolve.rs index d29e4beba..d73377b28 100644 --- a/crates/temper-platform/tests/trusted_issuer_resolve.rs +++ b/crates/temper-platform/tests/trusted_issuer_resolve.rs @@ -357,3 +357,58 @@ async fn revoking_a_grant_stops_the_agents_token_at_the_kernel() { "a revoked grant must stop resolving at the kernel immediately" ); } + +#[tokio::test] +async fn a_rejected_token_never_materialises_a_trusted_issuer() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + + // `iss` is attacker-chosen and read before any signature check, and entity + // reads spawn on demand — so this used to persist an Active TrustedIssuer + // row with empty fields, unauthenticated, for any string the caller chose. + let ghost = "https://ghost.attacker.example"; + let mut claims = contributor_claims(); + claims["iss"] = serde_json::json!(ghost); + let token = mint(&sk, header(), claims); + + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_none(), + "a token from an unregistered issuer must not resolve" + ); + assert!( + !state.server.entity_exists(&tenant, "TrustedIssuer", ghost), + "rejecting the token must not leave a TrustedIssuer row behind" + ); +} + +#[tokio::test] +async fn resolving_does_not_materialise_generation_rows() { + let sk = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let state = state_with_issuer(&sk).await; + let tenant = TenantId::new("default"); + let token = mint(&sk, header(), contributor_claims()); + + let resolver = IdentityResolver::new(); + assert!( + resolver + .resolve(&state.server, &tenant, &token) + .await + .is_some() + ); + + // Never-revoked principals read as generation 0 without persisting a + // counter row per subject and per grant. + for key in ["human-e2e", "grant-e2e"] { + assert!( + !state + .server + .entity_exists(&tenant, "PrincipalGeneration", key), + "resolving must not create a PrincipalGeneration row for {key}" + ); + } +} diff --git a/crates/temper-server/src/identity/resolver.rs b/crates/temper-server/src/identity/resolver.rs index 233f9858c..1fc1d3821 100644 --- a/crates/temper-server/src/identity/resolver.rs +++ b/crates/temper-server/src/identity/resolver.rs @@ -231,6 +231,14 @@ impl IdentityResolver { let unverified = jwt::decode_claims_unverified(token).ok()?; let issuer_id = unverified.iss; + // `iss` here is attacker-chosen and unverified, and entity reads spawn + // the entity on demand — so reading it directly would let anyone + // presenting a junk token persist an Active TrustedIssuer row with empty + // fields. Check existence first and never materialise one from a token. + if !state.entity_exists(tenant, "TrustedIssuer", &issuer_id) { + return None; + } + let issuer_response = state .get_tenant_entity_state(tenant, "TrustedIssuer", &issuer_id) .await @@ -338,6 +346,13 @@ impl IdentityResolver { tenant: &TenantId, key: &str, ) -> Option { + // Absent means never revoked. Check first rather than reading, because + // an entity read spawns the entity: otherwise every token would + // materialise a counter row keyed by its own subject and grant. + if !state.entity_exists(tenant, "PrincipalGeneration", key) { + return Some(0); + } + match state .get_tenant_entity_state(tenant, "PrincipalGeneration", key) .await From 8d525334dff519d5fe41a8ec3abffaba1e59d72d Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:57:04 -0400 Subject: [PATCH 11/11] docs: require three independent fresh-context reviews before calling work done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex, a second independent Codex Sol session, and an independent Fable — plus Greptile on the PR. In this effort each caught a different class the others missed: bypass surface, fail-open behaviour, and whether the thing actually runs. Two agreeing is not a reason to skip the third. Also records the lesson that let two P0s hide here: a gate proven against a permissive engine, and an end-to-end that only exercised the named action, were not proof. Test the production shape and probe the generic verbs. --- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index aa335a4a0..801b61d3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,3 +193,8 @@ Before deploying any spec change: 3. Entity actors hot-deploy without dropping existing state 4. OData endpoints respond correctly for all entity types 5. Telemetry emits WideEvents for all transitions + +## Definition of done — review bar + +- **Three independent fresh-context reviews before anything is "done".** Nothing counts as fully implemented until all three have reviewed it, each with NO prior context on the work: **Codex** (`codex exec --model gpt-5.6-sol -c model_reasoning_effort="high" --sandbox read-only`), **a second independent Codex Sol session**, and **an independent Fable** (a fresh Claude subagent, `model: fable`). Run **Greptile** on every PR too (`@greptile review` as a PR comment). Ask each for severity, `file:line`, and a concrete failure scenario, then fix everything they find — including findings that criticise your own fixes — and re-verify. They catch different classes: one finds bypass surface, one finds fail-open behaviour, one finds whether it actually runs. Two agreeing does not excuse skipping the third. +- **Test the production shape, not a convenient one.** A gate proven against a permissive or mock engine, and an end-to-end that only exercises the happy verb, are not proof — verify against the real policy/config set and probe the generic paths too (PATCH/PUT/DELETE, not only the named action). diff --git a/CLAUDE.md b/CLAUDE.md index 4dd12b3b2..354dc44fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,3 +193,8 @@ Before deploying any spec change: 3. Entity actors hot-deploy without dropping existing state 4. OData endpoints respond correctly for all entity types 5. Telemetry emits WideEvents for all transitions + +## Definition of done — review bar + +- **Three independent fresh-context reviews before anything is "done".** Nothing counts as fully implemented until all three have reviewed it, each with NO prior context on the work: **Codex** (`codex exec --model gpt-5.6-sol -c model_reasoning_effort="high" --sandbox read-only`), **a second independent Codex Sol session**, and **an independent Fable** (a fresh Claude subagent, `model: fable`). Run **Greptile** on every PR too (`@greptile review` as a PR comment). Ask each for severity, `file:line`, and a concrete failure scenario, then fix everything they find — including findings that criticise your own fixes — and re-verify. They catch different classes: one finds bypass surface, one finds fail-open behaviour, one finds whether it actually runs. Two agreeing does not excuse skipping the third. +- **Test the production shape, not a convenient one.** A gate proven against a permissive or mock engine, and an end-to-end that only exercises the happy verb, are not proof — verify against the real policy/config set and probe the generic paths too (PATCH/PUT/DELETE, not only the named action).