From c0206b80297d45239dac95827116e7771e86d03c Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Thu, 8 Oct 2026 09:19:23 -0500 Subject: [PATCH 1/3] feat(identity): compose request-context authority on credential resolution Recovered from the parked Codex claim codex-eg-identity-producer-01a10219 (isolated GR1080 checkout, base afcf5638). PrincipalResolution and IdentityReply gain a request-context parameter; the public Identity result carries RequestContextClaims composed at the live store boundary. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/eg-types/src/identity/projection.rs | 28 +- crates/eg-types/src/identity/views.rs | 34 +- .../eg-types/src/result_contract/security.rs | 2 +- src/server/dispatch/identity_store.rs | 14 +- .../identity_store/resolution_context.rs | 162 +++++ .../resolution_context/tests.rs | 612 ++++++++++++++++++ 6 files changed, 839 insertions(+), 13 deletions(-) create mode 100644 src/server/dispatch/identity_store/resolution_context.rs create mode 100644 src/server/dispatch/identity_store/resolution_context/tests.rs diff --git a/crates/eg-types/src/identity/projection.rs b/crates/eg-types/src/identity/projection.rs index 6d8e962ae..44ae6ac33 100644 --- a/crates/eg-types/src/identity/projection.rs +++ b/crates/eg-types/src/identity/projection.rs @@ -18,11 +18,14 @@ use super::store::IdentityStore; use super::{IdentityRefusal, RBAC_ROLE_PREFIX}; use crate::acl::{AgentIdentity, AgentRole, Grant, Role}; -/// What the local issuer puts in a principal's token. +/// Current principal projection. The store uses `()`; public issuance requires +/// `RequestContextClaims`, composed only after credential and policy validation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] #[cfg_attr(feature = "contract-schema", derive(schemars::JsonSchema))] -pub struct PrincipalResolution { +pub struct PrincipalResolution { + /// Required in the public specialization; never a default authority. + pub request_context: C, pub principal_id: String, pub username: String, pub kind: UserKind, @@ -40,6 +43,26 @@ pub struct PrincipalResolution { pub session_mfa_pending: bool, } +impl PrincipalResolution { + /// Attach server-derived authority without recomputing narrowed scopes. + pub fn with_request_context(self, request_context: C) -> PrincipalResolution { + PrincipalResolution { + request_context, + principal_id: self.principal_id, + username: self.username, + kind: self.kind, + status: self.status, + is_bootstrap: self.is_bootstrap, + roles: self.roles, + groups: self.groups, + scopes: self.scopes, + mfa_required: self.mfa_required, + mfa_enrolled: self.mfa_enrolled, + session_mfa_pending: self.session_mfa_pending, + } + } +} + /// The RBAC state the store owns. #[derive(Debug, Clone, Default)] pub struct RbacProjection { @@ -121,6 +144,7 @@ impl IdentityStore { .filter_map(|group| self.groups.get(group)) .any(|group| group.mfa_required); Ok(PrincipalResolution { + request_context: (), principal_id: principal_id.to_string(), username: user.username.clone(), kind: user.kind, diff --git a/crates/eg-types/src/identity/views.rs b/crates/eg-types/src/identity/views.rs index 87ed3572d..7459e9983 100644 --- a/crates/eg-types/src/identity/views.rs +++ b/crates/eg-types/src/identity/views.rs @@ -196,7 +196,7 @@ pub struct ResetDelivery { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] #[cfg_attr(feature = "contract-schema", derive(schemars::JsonSchema))] -pub enum IdentityReply { +pub enum IdentityReply { /// A write that answers nothing but whether it changed state. Done { changed: bool, @@ -207,7 +207,7 @@ pub enum IdentityReply { }, User(UserView), Users(Vec), - Resolution(PrincipalResolution), + Resolution(PrincipalResolution), Authenticate(AuthenticateResult), Sessions(Vec), Roles(Vec), @@ -225,3 +225,33 @@ pub enum IdentityReply { /// `issue_password_reset`. ResetDelivery(ResetDelivery), } + +impl IdentityReply { + /// Convert the raw store reply to the public authority-bearing reply. + /// Only resolution replies invoke the fallible producer. + pub fn try_with_request_context( + self, + compose: impl FnOnce(PrincipalResolution) -> Result, E>, + ) -> Result, E> { + Ok(match self { + Self::Config(value) => IdentityReply::Config(value), + Self::User(value) => IdentityReply::User(value), + Self::Users(value) => IdentityReply::Users(value), + Self::Authenticate(value) => IdentityReply::Authenticate(value), + Self::Sessions(value) => IdentityReply::Sessions(value), + Self::Roles(value) => IdentityReply::Roles(value), + Self::Groups(value) => IdentityReply::Groups(value), + Self::Idps(value) => IdentityReply::Idps(value), + Self::Audit(value) => IdentityReply::Audit(value), + Self::Sql(value) => IdentityReply::Sql(value), + Self::Provisioned(value) => IdentityReply::Provisioned(value), + Self::DirectoryGroup(value) => IdentityReply::DirectoryGroup(value), + Self::DirectoryGroups(value) => IdentityReply::DirectoryGroups(value), + Self::WebauthnCredentials(value) => IdentityReply::WebauthnCredentials(value), + Self::ResetDelivery(value) => IdentityReply::ResetDelivery(value), + Self::Done { changed } => IdentityReply::Done { changed }, + Self::Principal { principal_id } => IdentityReply::Principal { principal_id }, + Self::Resolution(value) => IdentityReply::Resolution(compose(value)?), + }) + } +} diff --git a/crates/eg-types/src/result_contract/security.rs b/crates/eg-types/src/result_contract/security.rs index 4ca607058..d530dd1cd 100644 --- a/crates/eg-types/src/result_contract/security.rs +++ b/crates/eg-types/src/result_contract/security.rs @@ -223,5 +223,5 @@ method_results! { CheckAccess(CheckAccess) => Json; // Every identity op answers one tagged reply; no reply variant can // carry a hash, token or sealed secret. - Identity(Identity) => Json; + Identity(Identity) => Json>; } diff --git a/src/server/dispatch/identity_store.rs b/src/server/dispatch/identity_store.rs index 0038a4e5f..b58583982 100644 --- a/src/server/dispatch/identity_store.rs +++ b/src/server/dispatch/identity_store.rs @@ -25,6 +25,7 @@ use crate::server::identity_view::identity_actor; mod exposure; #[cfg(test)] mod pause; +mod resolution_context; mod secrets; mod stamp; @@ -97,14 +98,11 @@ pub(crate) async fn dispatch_identity( if authority == ElevationStampAuthority::External && stamp.actor != identity_actor(context) { return Response::err(req_id, FORGED); } - let now_ms = super::authoritative_now_ms(); let mut guard = super::timed_write(state).await; - let outcome = guard.isolation.try_apply_identity( - &op, - &stamp, - now_ms, - &eg_capabilities::scopes::ScopeRegistry, - ); + // Credential expiry and session touches use time at apply, not before contention. + let now_ms = super::authoritative_now_ms(); + let outcome = + resolution_context::apply_and_compose(&mut guard, &op, &stamp, context, authority, now_ms); guard.publish_identity_view(); drop(guard); respond(req_id, &op, &stamp, outcome) @@ -114,7 +112,7 @@ fn respond( req_id: u64, op: &IdentityOp, stamp: &IdentityStamp, - outcome: Result, + outcome: Result, String>, ) -> Response { match outcome { Ok(reply) => { diff --git a/src/server/dispatch/identity_store/resolution_context.rs b/src/server/dispatch/identity_store/resolution_context.rs new file mode 100644 index 000000000..3584b89da --- /dev/null +++ b/src/server/dispatch/identity_store/resolution_context.rs @@ -0,0 +1,162 @@ +//! Public resolution authority is composed at the live store boundary, never +//! from a broker's arbitrary principal lookup. No delegation authority is +//! implemented here: a chain or broker scope cannot establish a subject login. + +use eg_types::acl::RequestContextClaims; +use eg_types::identity::{ + IdentityOp, IdentityRefusal, IdentityReply, IdentityStamp, IdentityStore, PrincipalResolution, + SessionOp, TokenOp, UserOp, +}; + +use super::{ElevationStampAuthority, ServerState, VerifiedRequestContext}; +use crate::server::auth::{request_context_policy, RequestContextPolicy}; +use crate::server::identity_view::identity_actor; + +// Use the existing closed error vocabulary: Response::err must preserve these +// as classified refusals, not collapse a new undeclared prefix to INTERNAL. +const NO_PROVENANCE: IdentityRefusal = IdentityRefusal::NotAuthorized; +const NO_AUTHORITY: IdentityRefusal = IdentityRefusal::NotAuthorized; + +/// Called with the server write lock held. A successful credential operation +/// supplies its own subject and already-narrowed scopes; callers cannot supply +/// a successful reply or a transferable `credential_verified` flag. +pub(super) fn apply_and_compose( + state: &mut ServerState, + op: &IdentityOp, + stamp: &IdentityStamp, + context: &VerifiedRequestContext, + authority: ElevationStampAuthority, + now_ms: u64, +) -> Result, String> { + let issuing = is_credential_resolution(op); + if matches!(op, IdentityOp::User(UserOp::Resolve { .. })) { + return Err(NO_PROVENANCE.to_string()); + } + let policy = if issuing { + let policy = request_context_policy() + .map_err(|_| IdentityRefusal::PreconditionFailed.to_string())?; + authorize_resolution( + state.isolation.rbac().identity_store(), + op, + stamp, + context, + authority, + policy, + )?; + Some(policy) + } else { + None + }; + let reply = state + .isolation + .try_apply_identity(op, stamp, now_ms, &eg_capabilities::scopes::ScopeRegistry) + .map_err(|error| error.to_string())?; + reply.try_with_request_context(|resolution| { + // Only a SUCCESSFUL Session::Resolve / Token::VerifyApiKey above can + // reach this producer. The lock remains held throughout composition. + let policy = policy.ok_or_else(|| NO_PROVENANCE.to_string())?; + if matches!(op, IdentityOp::Session(SessionOp::Resolve { .. })) { + let hash = stamp.token_hash(0).map_err(|error| error.to_string())?; + if state + .isolation + .rbac() + .identity_store() + .session_principal(hash, now_ms) + != Some(resolution.principal_id.as_str()) + { + return Err(NO_PROVENANCE.to_string()); + } + } + // resolve_session may already have durably touched a pending session. + // Refusing issuance does not revoke it or consume its MFA ceremony. + compose_resolution(resolution, policy) + }) +} + +fn is_credential_resolution(op: &IdentityOp) -> bool { + matches!( + op, + IdentityOp::Session(SessionOp::Resolve { .. }) + | IdentityOp::Token(TokenOp::VerifyApiKey { .. }) + ) +} + +fn authorize_resolution( + store: &IdentityStore, + op: &IdentityOp, + stamp: &IdentityStamp, + context: &VerifiedRequestContext, + authority: ElevationStampAuthority, + policy: &RequestContextPolicy, +) -> Result<(), String> { + let claims = context.claims(); + // Replicated contexts retain fingerprints, not live credential provenance. + // Unmanaged broker tokens have no independent current authority in this + // store. Keep them unavailable rather than treating token claims as a grant. + if !is_credential_resolution(op) + || authority != ElevationStampAuthority::External + || stamp.actor != identity_actor(context) + || stamp.actor.delegated + || claims.principal != claims.agent_id + || !claims.delegation.is_empty() + || !store.manages(&stamp.actor.principal_id) + { + return Err(NO_AUTHORITY.to_string()); + } + validate_policy(claims, policy)?; + store + .authorize(op, stamp, &eg_capabilities::scopes::ScopeRegistry) + .map_err(|error| error.to_string()) +} + +fn validate_policy( + claims: &RequestContextClaims, + policy: &RequestContextPolicy, +) -> Result<(), String> { + for (actual, expected) in [ + (&claims.tenant, &policy.expected_tenant), + (&claims.audience, &policy.expected_audience), + (&claims.policy_version, &policy.expected_policy_version), + ] { + if expected.trim().is_empty() || actual != expected { + return Err(IdentityRefusal::PreconditionFailed.to_string()); + } + } + Ok(()) +} + +fn compose_resolution( + resolution: PrincipalResolution, + policy: &RequestContextPolicy, +) -> Result, String> { + if !resolution.status.is_active() + || resolution.session_mfa_pending + || (resolution.mfa_required && !resolution.mfa_enrolled) + || resolution.principal_id.trim().is_empty() + || resolution + .roles + .iter() + .chain(resolution.scopes.iter()) + .any(|v| v.trim().is_empty()) + { + return Err(IdentityRefusal::NotAuthorized.to_string()); + } + let claims = RequestContextClaims { + principal: resolution.principal_id.clone(), + tenant: policy.expected_tenant.clone(), + audience: policy.expected_audience.clone(), + agent_id: resolution.principal_id.clone(), + roles: resolution.roles.iter().cloned().collect(), + // Preserve the successful operation's scope intersection/class filter. + scopes: resolution.scopes.iter().cloned().collect(), + policy_version: policy.expected_policy_version.clone(), + delegation: Vec::new(), + node: None, + priority: None, + }; + validate_policy(&claims, policy)?; + Ok(resolution.with_request_context(claims)) +} + +#[cfg(test)] +mod tests; diff --git a/src/server/dispatch/identity_store/resolution_context/tests.rs b/src/server/dispatch/identity_store/resolution_context/tests.rs new file mode 100644 index 000000000..a813b3067 --- /dev/null +++ b/src/server/dispatch/identity_store/resolution_context/tests.rs @@ -0,0 +1,612 @@ +//! Source regressions only: these fixtures do not qualify a deployed broker, +//! credential authority, generated client or mounted serving surface. +use super::*; +use eg_types::identity::{ + ApiKeyUse, ObjectRef, Secret, SessionTouch, UserKind, UserStatus, IDENTITY_AUTHENTICATE_SCOPE, +}; +use serde_json::json; + +fn policy() -> RequestContextPolicy { + RequestContextPolicy { + expected_tenant: "tenant".into(), + expected_audience: "engine".into(), + expected_policy_version: "policy-7".into(), + } +} + +fn resolution() -> PrincipalResolution { + PrincipalResolution { + request_context: (), + principal_id: "usr:alice".into(), + username: "alice".into(), + kind: UserKind::Human, + status: UserStatus::Active, + is_bootstrap: false, + roles: ["kg:write".into()].into(), + groups: Default::default(), + scopes: ["kg:read".into()].into(), + mfa_required: false, + mfa_enrolled: false, + session_mfa_pending: false, + } +} + +fn session_op() -> IdentityOp { + IdentityOp::Session(SessionOp::Resolve { + request: SessionTouch { + session_token: Secret::default(), + code: Secret::default(), + }, + }) +} + +fn context() -> VerifiedRequestContext { + let mut claims = compose_resolution(resolution(), &policy()) + .unwrap() + .request_context; + claims.principal = "svc:broker".into(); + claims.agent_id = claims.principal.clone(); + claims.scopes = vec![IDENTITY_AUTHENTICATE_SCOPE.into()]; + VerifiedRequestContext::from_verified_claims(claims, "test-only".into()) +} + +fn broker_store(status: &str, scopes: &[&str]) -> IdentityStore { + serde_json::from_value(json!({ + "users": {"svc:broker": { + "principal_id":"svc:broker", "username":"broker", "kind":"service", + "status":status, "source":"local", "roles":["broker"], "created_at_ms":1 + }}, + "roles": {"broker": {"role_id":"broker", "name":"broker", "scopes":scopes}} + })) + .unwrap() +} + +fn admission(store: &IdentityStore, ctx: &VerifiedRequestContext) -> Result<(), String> { + authorize_resolution( + store, + &session_op(), + &IdentityStamp::for_actor(identity_actor(ctx)), + ctx, + ElevationStampAuthority::External, + &policy(), + ) +} + +#[test] +fn preserves_narrow_scopes_without_role_union() { + let value = compose_resolution(resolution(), &policy()).unwrap(); + assert_eq!(value.request_context.scopes, vec!["kg:read"]); + assert_eq!(value.request_context.roles, vec!["kg:write"]); + assert_eq!(value.scopes, ["kg:read".into()].into()); + assert_eq!(value.request_context.principal, "usr:alice"); + assert_eq!(value.request_context.agent_id, "usr:alice"); + assert!(value.request_context.delegation.is_empty()); + assert_eq!(value.request_context.policy_version, "policy-7"); +} + +#[test] +fn inactive_and_pending_mfa_refuse() { + for status in [ + UserStatus::Disabled, + UserStatus::Deprovisioned, + UserStatus::PendingReset, + UserStatus::PendingVerification, + ] { + let mut raw = resolution(); + raw.status = status; + assert!(compose_resolution(raw, &policy()).is_err()); + } + let mut raw = resolution(); + raw.session_mfa_pending = true; + assert!(compose_resolution(raw, &policy()).is_err()); + let mut raw = resolution(); + raw.mfa_required = true; + assert!(compose_resolution(raw, &policy()).is_err()); +} + +#[test] +fn broker_lookup_is_not_credential_provenance() { + for id in ["usr:alice", "usr:bob", "svc:broker"] { + let op = IdentityOp::User(UserOp::Resolve { + request: ObjectRef { id: id.into() }, + }); + assert!(!is_credential_resolution(&op)); + let ctx = context(); + assert!(authorize_resolution( + &broker_store("active", &[IDENTITY_AUTHENTICATE_SCOPE]), + &op, + &IdentityStamp::for_actor(identity_actor(&ctx)), + &ctx, + ElevationStampAuthority::External, + &policy() + ) + .is_err()); + } +} + +#[test] +fn api_key_is_a_credential_operation_but_lookup_is_not() { + assert!(is_credential_resolution(&session_op())); + assert!(is_credential_resolution(&IdentityOp::Token( + TokenOp::VerifyApiKey { + request: ApiKeyUse { + key_id: "key".into(), + secret: Secret::default() + }, + } + ))); +} + +#[test] +fn unmanaged_broker_has_no_current_authority() { + assert!(admission(&IdentityStore::default(), &context()).is_err()); +} + +#[test] +fn managed_broker_requires_exact_current_scope_and_active_status() { + assert!(admission( + &broker_store("active", &[IDENTITY_AUTHENTICATE_SCOPE]), + &context() + ) + .is_ok()); + for scopes in [ + vec![], + vec!["identity:admin"], + vec!["identity:read"], + vec!["identity:*"], + ] { + assert!(admission(&broker_store("active", &scopes), &context()).is_err()); + } + for status in ["disabled", "deprovisioned"] { + assert!(admission( + &broker_store(status, &[IDENTITY_AUTHENTICATE_SCOPE]), + &context() + ) + .is_err()); + } +} + +#[test] +fn roles_only_and_unsigned_scope_widening_do_not_authorize_broker() { + let mut claims = context().claims().clone(); + claims.scopes.clear(); + claims.roles = vec![IDENTITY_AUTHENTICATE_SCOPE.into()]; + let ctx = VerifiedRequestContext::from_verified_claims(claims, "test".into()); + assert!(admission( + &broker_store("active", &[IDENTITY_AUTHENTICATE_SCOPE]), + &ctx + ) + .is_err()); +} + +#[test] +fn delegated_or_mismatched_agent_broker_refuses() { + for chain in [vec![], vec!["svc:broker".into(), "svc:other".into()]] { + let mut claims = context().claims().clone(); + claims.agent_id = "svc:other".into(); + claims.delegation = chain; + let ctx = VerifiedRequestContext::from_verified_claims(claims, "test".into()); + assert!(admission( + &broker_store("active", &[IDENTITY_AUTHENTICATE_SCOPE]), + &ctx + ) + .is_err()); + } +} + +#[test] +fn forged_stamp_and_replicated_context_cannot_issue() { + let ctx = context(); + let store = broker_store("active", &[IDENTITY_AUTHENTICATE_SCOPE]); + let mut stamp = IdentityStamp::for_actor(identity_actor(&ctx)); + stamp.actor.principal_id = "usr:alice".into(); + assert!(authorize_resolution( + &store, + &session_op(), + &stamp, + &ctx, + ElevationStampAuthority::External, + &policy() + ) + .is_err()); + assert!(authorize_resolution( + &store, + &session_op(), + &IdentityStamp::for_actor(identity_actor(&ctx)), + &ctx, + ElevationStampAuthority::Replicated, + &policy() + ) + .is_err()); +} + +#[test] +fn deployment_binding_rejects_missing_and_mismatched_values() { + for field in ["tenant", "audience", "policy_version"] { + let original = serde_json::to_value(context().claims()).unwrap(); + for replacement in ["", "wrong"] { + let mut value = original.clone(); + value[field] = json!(replacement); + let claims: RequestContextClaims = serde_json::from_value(value).unwrap(); + assert!(validate_policy(&claims, &policy()).is_err()); + } + } + let mut missing = policy(); + missing.expected_policy_version.clear(); + assert!(compose_resolution(resolution(), &missing).is_err()); +} + +#[test] +fn public_wire_requires_nonnull_strict_context() { + let public = IdentityReply::Resolution(compose_resolution(resolution(), &policy()).unwrap()); + let wire = serde_json::to_value(public).unwrap(); + assert!(serde_json::from_value::>(wire.clone()).is_ok()); + for field in [ + "principal", + "tenant", + "audience", + "agent_id", + "roles", + "scopes", + "policy_version", + "delegation", + ] { + let mut bad = wire.clone(); + bad["value"]["request_context"] + .as_object_mut() + .unwrap() + .remove(field); + assert!(serde_json::from_value::>(bad).is_err()); + } + let mut missing = wire.clone(); + missing["value"] + .as_object_mut() + .unwrap() + .remove("request_context"); + assert!(serde_json::from_value::>(missing).is_err()); + let mut null = wire.clone(); + null["value"]["request_context"] = json!(null); + assert!(serde_json::from_value::>(null).is_err()); + let mut extra = wire; + extra["value"]["request_context"]["oidc_token"] = json!("not-a-context-field"); + assert!(serde_json::from_value::>(extra).is_err()); +} + +#[test] +fn nonresolution_reply_does_not_request_authority() { + let raw: IdentityReply = IdentityReply::Done { changed: false }; + let public: Result, String> = + raw.try_with_request_context(|_| panic!("nonresolution must not enter issuance")); + assert!(matches!( + public.unwrap(), + IdentityReply::Done { changed: false } + )); +} + +#[tokio::test] +async fn arbitrary_broker_lookup_refuses_before_store_change() { + let state = crate::server::dispatch::test_support::bootstrapped_state(); + let mut state = state.write().await; + let before = state.isolation.rbac().identity_store().clone(); + let ctx = context(); + let op = IdentityOp::User(UserOp::Resolve { + request: ObjectRef { + id: "usr:alice".into(), + }, + }); + let result = apply_and_compose( + &mut state, + &op, + &IdentityStamp::for_actor(identity_actor(&ctx)), + &ctx, + ElevationStampAuthority::External, + 100, + ); + assert_eq!(result.unwrap_err(), NO_PROVENANCE.to_string()); + assert_eq!(state.isolation.rbac().identity_store(), &before); +} + +#[tokio::test] +async fn unauthorized_broker_cannot_use_subject_credential() { + let state = crate::server::dispatch::test_support::bootstrapped_state(); + let mut state = state.write().await; + let before = state.isolation.rbac().identity_store().clone(); + let ctx = context(); + let result = apply_and_compose( + &mut state, + &session_op(), + &IdentityStamp::for_actor(identity_actor(&ctx)), + &ctx, + ElevationStampAuthority::External, + 100, + ); + assert!(result.is_err()); + assert_eq!(state.isolation.rbac().identity_store(), &before); +} + +use crate::protocol::{Method, Response, ResultPayload}; +use crate::server::dispatch::test_support::{bootstrapped_state, send, verified}; +use eg_types::identity::{ + AccessOp, AuthMode, AuthenticateRequest, ConfigOp, CredentialOp, InitializeRequest, MfaOp, + BOOTSTRAP_PRINCIPAL, IDENTITY_ADMIN_SCOPE, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +const PASSWORD: &str = "source regression password phrase"; +const SESSION: &str = "source-session-0123456789abcdefghijklmnopqrstuv"; + +fn live_broker() -> VerifiedRequestContext { + verified("svc:broker", &[IDENTITY_AUTHENTICATE_SCOPE], &[]) +} + +async fn send_op( + state: &Arc>, + ctx: VerifiedRequestContext, + op: IdentityOp, +) -> Response { + send(state, ctx, Method::Identity { op, stamp: None }).await +} + +fn session_request() -> IdentityOp { + IdentityOp::Session(SessionOp::Resolve { + request: SessionTouch { + session_token: Secret::new(SESSION), + code: Secret::default(), + }, + }) +} + +/// Test-only in-memory engine, initialized through the existing boundary. +/// This creates no live grants and is not an installed-authority qualification. +async fn ready_engine() -> Arc> { + let state = bootstrapped_state(); + let initialized = send_op( + &state, + live_broker(), + IdentityOp::Config(ConfigOp::Initialize { + request: InitializeRequest { + mode: AuthMode::Local, + admin_username: Some("operator".into()), + admin_password: Secret::new(PASSWORD), + }, + }), + ) + .await; + assert!(initialized.error.is_none(), "{initialized:?}"); + let admin = || verified(BOOTSTRAP_PRINCIPAL, &[IDENTITY_ADMIN_SCOPE], &[]); + let role = send_op( + &state, + admin(), + IdentityOp::Access(AccessOp::UpsertRole { + request: serde_json::from_value(json!({"role_id":"broker", "name":"broker", + "scopes":[IDENTITY_AUTHENTICATE_SCOPE]})) + .unwrap(), + }), + ) + .await; + assert!(role.error.is_none(), "{role:?}"); + let user = send_op( + &state, + admin(), + IdentityOp::User(UserOp::Create { + request: serde_json::from_value(json!({"username":"broker", "kind":"service", + "principal_id":"svc:broker", "roles":["broker"]})) + .unwrap(), + }), + ) + .await; + assert!(user.error.is_none(), "{user:?}"); + let login = send_op( + &state, + live_broker(), + IdentityOp::Credential(CredentialOp::Authenticate { + request: AuthenticateRequest { + username: "operator".into(), + password: Secret::new(PASSWORD), + session_token: Secret::new(SESSION), + ip_prefix: None, + new_password: Secret::default(), + }, + }), + ) + .await; + assert!(login.error.is_none(), "{login:?}"); + let wire = serde_json::to_value(&login).unwrap(); + assert_eq!(wire["result"]["value"]["outcome"], "ok"); + state +} + +fn resolved(response: &Response) -> PrincipalResolution { + assert!(response.error.is_none(), "{response:?}"); + let Some(ResultPayload::Json(value)) = &response.result else { + panic!("missing JSON response") + }; + match serde_json::from_value::>(value.clone()).unwrap() { + IdentityReply::Resolution(value) => value, + _ => panic!("expected authoritative resolution"), + } +} + +fn assert_refusal(response: &Response, expected: IdentityRefusal) { + let wire = serde_json::to_value(response).unwrap(); + assert_eq!(wire["error"], expected.to_string()); + assert!(wire.get("result").is_none()); + assert_ne!(wire["error_detail"], "unclassified engine refusal"); +} + +#[tokio::test] +async fn successful_session_resolution_runs_boundary_store_and_public_response() { + let state = ready_engine().await; + let response = send_op(&state, live_broker(), session_request()).await; + let value = resolved(&response); + assert_eq!(value.principal_id, BOOTSTRAP_PRINCIPAL); + assert_eq!(value.request_context.principal, BOOTSTRAP_PRINCIPAL); + assert_eq!(value.request_context.agent_id, BOOTSTRAP_PRINCIPAL); + assert!(value.request_context.delegation.is_empty()); + assert_eq!( + value.request_context.scopes, + value.scopes.iter().cloned().collect::>() + ); + assert_eq!( + value.request_context.roles, + value.roles.iter().cloned().collect::>() + ); + assert_eq!( + value.request_context.policy_version, + request_context_policy().unwrap().expected_policy_version + ); +} + +#[test] +fn all_producer_refusals_survive_final_response_classification() { + let ctx = context(); + let stamp = IdentityStamp::for_actor(identity_actor(&ctx)); + let mut bad_policy = policy(); + bad_policy.expected_policy_version.clear(); + let mut pending = resolution(); + pending.session_mfa_pending = true; + let cases = [ + (NO_PROVENANCE.to_string(), IdentityRefusal::NotAuthorized), + ( + admission(&IdentityStore::default(), &ctx).unwrap_err(), + IdentityRefusal::NotAuthorized, + ), + ( + validate_policy(ctx.claims(), &bad_policy).unwrap_err(), + IdentityRefusal::PreconditionFailed, + ), + ( + compose_resolution(pending, &policy()).unwrap_err(), + IdentityRefusal::NotAuthorized, + ), + ]; + for (error, expected) in cases { + let response = super::super::respond(9, &session_op(), &stamp, Err(error)); + assert_refusal(&response, expected); + } +} + +/// Edit an in-memory fixture image through the policy-store adapter, then load +/// it normally. Used only to put session time/MFA at precise test boundaries. +fn edit_fixture_identity(state: &mut ServerState, edit: impl FnOnce(&mut serde_json::Value)) { + let adapter = state.isolation.policy_store().unwrap(); + let (policy, identities, bootstrap) = adapter.load().unwrap(); + let mut value = serde_json::to_value(policy).unwrap(); + edit(&mut value["identity"]); + let policy = serde_json::from_value(value).unwrap(); + adapter.save(&policy, &identities, bootstrap).unwrap(); + state.isolation = crate::isolation::IsolationLayer::with_policy_store(adapter).unwrap(); +} + +#[tokio::test] +async fn credential_expiring_while_waiting_for_write_lock_refuses_without_touch() { + use std::future::Future; + use std::task::Poll; + let state = ready_engine().await; + let ctx = live_broker(); + let mut method = Method::Identity { + op: session_request(), + stamp: None, + }; + super::super::stamp_identity(&state, &mut method, &ctx, ElevationStampAuthority::External) + .await + .unwrap(); + let Method::Identity { op, stamp } = method else { + unreachable!() + }; + let mut guard = state.write().await; + let hash = super::super::secrets::token_hash(SESSION); + let before_poll = crate::server::dispatch::authoritative_now_ms(); + let expires = before_poll + 200; + let old_touch = before_poll - 120_000; + edit_fixture_identity(&mut guard, |identity| { + identity["sessions"][&hash]["idle_expires_at_ms"] = json!(expires); + identity["sessions"][&hash]["last_seen_at_ms"] = json!(old_touch); + }); + let before = guard.isolation.rbac().identity_store().clone(); + let mut blocked = Box::pin(super::super::dispatch_identity( + &state, + 10, + &ctx, + ElevationStampAuthority::External, + (op, stamp), + )); + // Poll once while holding the lock: the old implementation samples its + // stale timestamp here; the fixed implementation must still await the lock. + std::future::poll_fn(|cx| { + assert!(blocked.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + assert!( + crate::server::dispatch::authoritative_now_ms() < expires, + "fixture must start live" + ); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while crate::server::dispatch::authoritative_now_ms() < expires { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("bounded expiry wait"); + drop(guard); + let response = blocked.await; + assert_refusal(&response, IdentityRefusal::NotFound); + assert_eq!( + state.read().await.isolation.rbac().identity_store(), + &before + ); +} + +#[tokio::test] +async fn pending_mfa_refusal_preserves_a_completable_ceremony_after_touch() { + let state = ready_engine().await; + let hash = super::super::secrets::token_hash(SESSION); + let code = "recovery-0123456789abcdefghijklmnop"; + let code_hash = super::super::secrets::token_hash(code); + let now = crate::server::dispatch::authoritative_now_ms(); + { + let mut guard = state.write().await; + edit_fixture_identity(&mut guard, |identity| { + identity["sessions"][&hash]["mfa_pending"] = json!(true); + identity["sessions"][&hash]["last_seen_at_ms"] = json!(now - 120_000); + identity["totp"] = json!({(BOOTSTRAP_PRINCIPAL): { + "sealed_secret":"fixture-unused-by-recovery", "confirmed_at_ms":1, "last_step":0 + }}); + identity["recovery"] = json!({(BOOTSTRAP_PRINCIPAL): [{ + "code_hash":code_hash, "used_at_ms":null + }]}); + }); + } + let refused = send_op(&state, live_broker(), session_request()).await; + assert_refusal(&refused, IdentityRefusal::NotAuthorized); + let stored = + serde_json::to_value(state.read().await.isolation.rbac().identity_store()).unwrap(); + assert_eq!(stored["sessions"][&hash]["mfa_pending"], true); + assert!( + stored["sessions"][&hash]["last_seen_at_ms"] + .as_u64() + .unwrap() + >= now + ); + assert!(stored["recovery"][BOOTSTRAP_PRINCIPAL][0]["used_at_ms"].is_null()); + let completed = send_op( + &state, + live_broker(), + IdentityOp::Mfa(MfaOp::ConsumeRecoveryCode { + request: SessionTouch { + session_token: Secret::new(SESSION), + code: Secret::new(code), + }, + }), + ) + .await; + assert!(completed.error.is_none(), "{completed:?}"); + assert_eq!( + serde_json::to_value(&completed).unwrap()["result"]["value"]["outcome"], + "ok" + ); + let response = send_op(&state, live_broker(), session_request()).await; + assert!(!resolved(&response).session_mfa_pending); +} From 40ed59058e31b23fb1b6919516619343ceba8e54 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Thu, 8 Oct 2026 13:35:03 -0500 Subject: [PATCH 2/3] fix(identity): restore with_policy_store seam; regenerate contract after main merge Ports the IsolationLayer::with_policy_store adapter seam from Codex 6a235113e that the resolution-context tests use. Regenerates contract artifacts with gen_contract for the changed Identity result type. Co-Authored-By: Claude Opus 5.5 (1M context) --- contract/receipt.json | 8 +- contract/schemas/result.security.json | 79 ++++++++++++++++++- .../generated/catalog_digest.rs | 2 +- crates/eg-core/src/isolation/layer_store.rs | 12 ++- epistemic_graph/contract/receipt.json | 8 +- .../contract/schemas/result.security.json | 79 ++++++++++++++++++- epistemic_graph/generated/models.py | 1 + 7 files changed, 177 insertions(+), 12 deletions(-) diff --git a/contract/receipt.json b/contract/receipt.json index eb3d9039f..c3dbebf8c 100644 --- a/contract/receipt.json +++ b/contract/receipt.json @@ -15,7 +15,7 @@ "contract/schemas/result.payload.json": "1bd5261c36024a16810f262e65449f3cf81de819c1d8189abd68a75133cae8c8", "contract/schemas/result.query.json": "b21f5cfb583971723afffe4fdd46ae0097bc462a1749ca6d21c93df020edad85", "contract/schemas/result.reasoning.json": "28635fa0421d816cc61cea7af61f12b949b374fc7b808c7483d29e5857950d49", - "contract/schemas/result.security.json": "6f9eddc71dcedb51f65cb6b5a100478f7de09a429e894f100048198a0ad5f3bc", + "contract/schemas/result.security.json": "02b8ce8e2e11db3870f481e4fb60d467e74093d5c6429fe7179f5ba4d3173ec3", "contract/schemas/result.storage.json": "a8f2cf40f9331e7a9688869d7c7363ab73e5ee940a6fd1ad9687b01453c98f55", "contract/schemas/result.transactions.json": "0b088662b42178c85ec0ac106f83937b6d51c4ec9214fdb18ea5e6523b159b25", "contract/scopes.json": "1cee561b95b664965005d01fe0e7a876932477aea9a0355b6f20e8072df0c9eb", @@ -34,7 +34,7 @@ "epistemic_graph/contract/schemas/result.payload.json": "1bd5261c36024a16810f262e65449f3cf81de819c1d8189abd68a75133cae8c8", "epistemic_graph/contract/schemas/result.query.json": "b21f5cfb583971723afffe4fdd46ae0097bc462a1749ca6d21c93df020edad85", "epistemic_graph/contract/schemas/result.reasoning.json": "28635fa0421d816cc61cea7af61f12b949b374fc7b808c7483d29e5857950d49", - "epistemic_graph/contract/schemas/result.security.json": "6f9eddc71dcedb51f65cb6b5a100478f7de09a429e894f100048198a0ad5f3bc", + "epistemic_graph/contract/schemas/result.security.json": "02b8ce8e2e11db3870f481e4fb60d467e74093d5c6429fe7179f5ba4d3173ec3", "epistemic_graph/contract/schemas/result.storage.json": "a8f2cf40f9331e7a9688869d7c7363ab73e5ee940a6fd1ad9687b01453c98f55", "epistemic_graph/contract/schemas/result.transactions.json": "0b088662b42178c85ec0ac106f83937b6d51c4ec9214fdb18ea5e6523b159b25", "epistemic_graph/contract/scopes.json": "1cee561b95b664965005d01fe0e7a876932477aea9a0355b6f20e8072df0c9eb", @@ -58,7 +58,7 @@ "epistemic_graph/generated/ingestion.py": "2735ad462eb460209318ecd6e898b2e1de81cc92388ac37f27fb149afacfbc5f", "epistemic_graph/generated/managed_index.py": "2359e4b871d4ef359c189ac8debb077fbc04cda00280d42bc6a84aacf3da04d1", "epistemic_graph/generated/messaging.py": "0c00d8d93ed7fba40eeeb572c14c7354eaa33693e8acda369942ee9598907e1f", - "epistemic_graph/generated/models.py": "c52786f9b95f6d816b543dcf97c0bae8da8ceed8d0f2708767f4868d08518cec", + "epistemic_graph/generated/models.py": "a5f2de6b31f9d3243520871566005bf963468ab39d06fcf48ee201b2f67445a2", "epistemic_graph/generated/policy_evolution.py": "c9a836d2777c9ce2991236db4656966598d0de3463a574f1b5b05780c9be2205", "epistemic_graph/generated/query.py": "85db01171e8f4278b841872939cbed7d47d76415b5516b67dd83eaa8e16b3201", "epistemic_graph/generated/rdf_report.py": "600708e31131a12a37ec4a890490bbd3a0806a16b4961b3a0916d213ef535d49", @@ -71,7 +71,7 @@ "epistemic_graph/generated/transactions.py": "83174fd4a8d2032577c60bf61dacf08fbe3b5df8530da66a19d1b36eb9920335", "epistemic_graph/generated/write_back.py": "cbd29e0308e7e095abf84d5f31a67ce88ea46c69f8f39a95369a03ce82c21df0" }, - "contract_digest": "cca301febcdb7c977970e932b68f48b621f8154df2a0c9594201130efe88bb10", + "contract_digest": "488f3feb732b2389f8f5fc2875f6cc61086d93b28f852bfd0bc9379ebeee504a", "contract_version": 1, "feature_profile": [ "contract", diff --git a/contract/schemas/result.security.json b/contract/schemas/result.security.json index bd1feaecb..e23b863f4 100644 --- a/contract/schemas/result.security.json +++ b/contract/schemas/result.security.json @@ -1371,7 +1371,7 @@ }, "PrincipalResolution": { "additionalProperties": false, - "description": "What the local issuer puts in a principal's token.", + "description": "Current principal projection. The store uses `()`; public issuance requires\n`RequestContextClaims`, composed only after credential and policy validation.", "properties": { "groups": { "items": { @@ -1396,6 +1396,10 @@ "principal_id": { "type": "string" }, + "request_context": { + "$ref": "#/$defs/RequestContextClaims", + "description": "Required in the public specialization; never a default authority." + }, "roles": { "items": { "type": "string" @@ -1424,6 +1428,7 @@ } }, "required": [ + "request_context", "principal_id", "username", "kind", @@ -1515,6 +1520,78 @@ } ] }, + "RequestContextClaims": { + "additionalProperties": false, + "description": "Identity and policy claims carried by a verified request-context envelope.\n\nThis is the wire representation only. Callers must not treat a deserialized\nvalue as trusted until the server has verified the envelope MAC, deployment\naudience/tenant/policy version, request binding, and delegation chain. The\nserver exposes a separate, non-constructible `VerifiedRequestContext` wrapper\nafter those checks succeed.", + "properties": { + "agent_id": { + "description": "Effective agent identity used for ACL/RBAC decisions.", + "type": "string" + }, + "audience": { + "description": "Intended service audience.", + "type": "string" + }, + "delegation": { + "description": "Ordered delegation path from `principal` to `agent_id` (inclusive).\nEmpty means no delegation and therefore requires principal == agent_id.", + "items": { + "type": "string" + }, + "type": "array" + }, + "node": { + "description": "Target node id this envelope was minted for (ADR-3 / W1.9 node-bound\nenvelopes — ``reports/wave1/ADR-scale-trio.md``). `None` for a client\nthat predates node binding, or one that has not yet learned a target\nnode id; a single-node deployment never needs to set this. When\n`Some(id)`, the server exact-matches it against the receiving node's\nown identity before dispatch (`server::auth::validate_context_claims`)\n— a mismatch means the envelope was captured and replayed against a\ndifferent cluster member. `#[serde(default)]` so an envelope minted by\na client that has never heard of this claim deserializes exactly as\nbefore (absent, not a hard error).", + "type": [ + "string", + "null" + ] + }, + "policy_version": { + "description": "Exact policy bundle/version under which the context was issued.", + "type": "string" + }, + "principal": { + "description": "Authenticated subject that originated the request.", + "type": "string" + }, + "priority": { + "description": "Advisory QoS admission-priority class this request declares (W2.4 —\nengine-native QoS lanes). One of the agent-utilities `PriorityClass`\nwire values `\"interactive\"` / `\"orchestration\"` / `\"hydration\"` /\n`\"background_ingestion\"`; any other or absent value is treated as the\norchestration default (high, never starved — matching au's\nuntagged-context semantics). Mapped to a [`crate`]-independent admission\nclass by the server's QoS scheduler (`server::qos::QosClass`).\n\nIt is **MAC-covered** (appended to `build_envelope_v2_bytes` as a\ndistinct tag-`2` optional trailer, after the tag-`1` node trailer) so a\nnoisy principal cannot forge a higher class to defeat the admission\nordering — the priority is bound to the signed envelope exactly like\nevery other claim. `#[serde(default)]` + presence-gated encoding keep an\nenvelope minted by a client that predates this claim byte-for-byte\nidentical to before (absent, not a hard error) — fully additive.", + "type": [ + "string", + "null" + ] + }, + "roles": { + "description": "Identity-provider roles asserted for policy evaluation/audit.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scopes": { + "description": "Fine-grained capabilities asserted for policy evaluation/audit.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant": { + "description": "Tenant security boundary selected by the deployment.", + "type": "string" + } + }, + "required": [ + "principal", + "tenant", + "audience", + "agent_id", + "roles", + "scopes", + "policy_version", + "delegation" + ], + "type": "object" + }, "ResetDelivery": { "additionalProperties": false, "description": "Where a self-service reset link goes (`None`: nowhere, uniformly).", diff --git a/crates/eg-capabilities/generated/catalog_digest.rs b/crates/eg-capabilities/generated/catalog_digest.rs index de735125a..357355d88 100644 --- a/crates/eg-capabilities/generated/catalog_digest.rs +++ b/crates/eg-capabilities/generated/catalog_digest.rs @@ -7,4 +7,4 @@ /// `contract/receipt.json`'s `contract_digest`. pub const CONTRACT_CATALOG_DIGEST: &str = - "cca301febcdb7c977970e932b68f48b621f8154df2a0c9594201130efe88bb10"; + "488f3feb732b2389f8f5fc2875f6cc61086d93b28f852bfd0bc9379ebeee504a"; diff --git a/crates/eg-core/src/isolation/layer_store.rs b/crates/eg-core/src/isolation/layer_store.rs index 493e2b70e..c4e468a35 100644 --- a/crates/eg-core/src/isolation/layer_store.rs +++ b/crates/eg-core/src/isolation/layer_store.rs @@ -36,6 +36,16 @@ impl IsolationLayer { proof: &[u8], ) -> Result { let store = crate::rbac_persist::RbacStore::open(dir, verifier, principal, proof)?; + Self::with_policy_store(std::sync::Arc::new(store)) + } + + /// Native adapter seam. It loads and validates the same complete durable + /// image as the directory opener. Atomic-store fault fixtures also use it. + #[doc(hidden)] + #[cfg(feature = "security")] + pub fn with_policy_store( + store: std::sync::Arc, + ) -> Result { let (rbac, identities, identity_bootstrap) = store.load()?; if identity_bootstrap == crate::rbac_persist::IdentityBootstrapState::Pending && !holds_only_identity_store_state(&rbac, &identities) @@ -49,7 +59,7 @@ impl IsolationLayer { agents, rbac, identity_bootstrap, - persist: Some(std::sync::Arc::new(store)), + persist: Some(store), }) } diff --git a/epistemic_graph/contract/receipt.json b/epistemic_graph/contract/receipt.json index eb3d9039f..c3dbebf8c 100644 --- a/epistemic_graph/contract/receipt.json +++ b/epistemic_graph/contract/receipt.json @@ -15,7 +15,7 @@ "contract/schemas/result.payload.json": "1bd5261c36024a16810f262e65449f3cf81de819c1d8189abd68a75133cae8c8", "contract/schemas/result.query.json": "b21f5cfb583971723afffe4fdd46ae0097bc462a1749ca6d21c93df020edad85", "contract/schemas/result.reasoning.json": "28635fa0421d816cc61cea7af61f12b949b374fc7b808c7483d29e5857950d49", - "contract/schemas/result.security.json": "6f9eddc71dcedb51f65cb6b5a100478f7de09a429e894f100048198a0ad5f3bc", + "contract/schemas/result.security.json": "02b8ce8e2e11db3870f481e4fb60d467e74093d5c6429fe7179f5ba4d3173ec3", "contract/schemas/result.storage.json": "a8f2cf40f9331e7a9688869d7c7363ab73e5ee940a6fd1ad9687b01453c98f55", "contract/schemas/result.transactions.json": "0b088662b42178c85ec0ac106f83937b6d51c4ec9214fdb18ea5e6523b159b25", "contract/scopes.json": "1cee561b95b664965005d01fe0e7a876932477aea9a0355b6f20e8072df0c9eb", @@ -34,7 +34,7 @@ "epistemic_graph/contract/schemas/result.payload.json": "1bd5261c36024a16810f262e65449f3cf81de819c1d8189abd68a75133cae8c8", "epistemic_graph/contract/schemas/result.query.json": "b21f5cfb583971723afffe4fdd46ae0097bc462a1749ca6d21c93df020edad85", "epistemic_graph/contract/schemas/result.reasoning.json": "28635fa0421d816cc61cea7af61f12b949b374fc7b808c7483d29e5857950d49", - "epistemic_graph/contract/schemas/result.security.json": "6f9eddc71dcedb51f65cb6b5a100478f7de09a429e894f100048198a0ad5f3bc", + "epistemic_graph/contract/schemas/result.security.json": "02b8ce8e2e11db3870f481e4fb60d467e74093d5c6429fe7179f5ba4d3173ec3", "epistemic_graph/contract/schemas/result.storage.json": "a8f2cf40f9331e7a9688869d7c7363ab73e5ee940a6fd1ad9687b01453c98f55", "epistemic_graph/contract/schemas/result.transactions.json": "0b088662b42178c85ec0ac106f83937b6d51c4ec9214fdb18ea5e6523b159b25", "epistemic_graph/contract/scopes.json": "1cee561b95b664965005d01fe0e7a876932477aea9a0355b6f20e8072df0c9eb", @@ -58,7 +58,7 @@ "epistemic_graph/generated/ingestion.py": "2735ad462eb460209318ecd6e898b2e1de81cc92388ac37f27fb149afacfbc5f", "epistemic_graph/generated/managed_index.py": "2359e4b871d4ef359c189ac8debb077fbc04cda00280d42bc6a84aacf3da04d1", "epistemic_graph/generated/messaging.py": "0c00d8d93ed7fba40eeeb572c14c7354eaa33693e8acda369942ee9598907e1f", - "epistemic_graph/generated/models.py": "c52786f9b95f6d816b543dcf97c0bae8da8ceed8d0f2708767f4868d08518cec", + "epistemic_graph/generated/models.py": "a5f2de6b31f9d3243520871566005bf963468ab39d06fcf48ee201b2f67445a2", "epistemic_graph/generated/policy_evolution.py": "c9a836d2777c9ce2991236db4656966598d0de3463a574f1b5b05780c9be2205", "epistemic_graph/generated/query.py": "85db01171e8f4278b841872939cbed7d47d76415b5516b67dd83eaa8e16b3201", "epistemic_graph/generated/rdf_report.py": "600708e31131a12a37ec4a890490bbd3a0806a16b4961b3a0916d213ef535d49", @@ -71,7 +71,7 @@ "epistemic_graph/generated/transactions.py": "83174fd4a8d2032577c60bf61dacf08fbe3b5df8530da66a19d1b36eb9920335", "epistemic_graph/generated/write_back.py": "cbd29e0308e7e095abf84d5f31a67ce88ea46c69f8f39a95369a03ce82c21df0" }, - "contract_digest": "cca301febcdb7c977970e932b68f48b621f8154df2a0c9594201130efe88bb10", + "contract_digest": "488f3feb732b2389f8f5fc2875f6cc61086d93b28f852bfd0bc9379ebeee504a", "contract_version": 1, "feature_profile": [ "contract", diff --git a/epistemic_graph/contract/schemas/result.security.json b/epistemic_graph/contract/schemas/result.security.json index bd1feaecb..e23b863f4 100644 --- a/epistemic_graph/contract/schemas/result.security.json +++ b/epistemic_graph/contract/schemas/result.security.json @@ -1371,7 +1371,7 @@ }, "PrincipalResolution": { "additionalProperties": false, - "description": "What the local issuer puts in a principal's token.", + "description": "Current principal projection. The store uses `()`; public issuance requires\n`RequestContextClaims`, composed only after credential and policy validation.", "properties": { "groups": { "items": { @@ -1396,6 +1396,10 @@ "principal_id": { "type": "string" }, + "request_context": { + "$ref": "#/$defs/RequestContextClaims", + "description": "Required in the public specialization; never a default authority." + }, "roles": { "items": { "type": "string" @@ -1424,6 +1428,7 @@ } }, "required": [ + "request_context", "principal_id", "username", "kind", @@ -1515,6 +1520,78 @@ } ] }, + "RequestContextClaims": { + "additionalProperties": false, + "description": "Identity and policy claims carried by a verified request-context envelope.\n\nThis is the wire representation only. Callers must not treat a deserialized\nvalue as trusted until the server has verified the envelope MAC, deployment\naudience/tenant/policy version, request binding, and delegation chain. The\nserver exposes a separate, non-constructible `VerifiedRequestContext` wrapper\nafter those checks succeed.", + "properties": { + "agent_id": { + "description": "Effective agent identity used for ACL/RBAC decisions.", + "type": "string" + }, + "audience": { + "description": "Intended service audience.", + "type": "string" + }, + "delegation": { + "description": "Ordered delegation path from `principal` to `agent_id` (inclusive).\nEmpty means no delegation and therefore requires principal == agent_id.", + "items": { + "type": "string" + }, + "type": "array" + }, + "node": { + "description": "Target node id this envelope was minted for (ADR-3 / W1.9 node-bound\nenvelopes — ``reports/wave1/ADR-scale-trio.md``). `None` for a client\nthat predates node binding, or one that has not yet learned a target\nnode id; a single-node deployment never needs to set this. When\n`Some(id)`, the server exact-matches it against the receiving node's\nown identity before dispatch (`server::auth::validate_context_claims`)\n— a mismatch means the envelope was captured and replayed against a\ndifferent cluster member. `#[serde(default)]` so an envelope minted by\na client that has never heard of this claim deserializes exactly as\nbefore (absent, not a hard error).", + "type": [ + "string", + "null" + ] + }, + "policy_version": { + "description": "Exact policy bundle/version under which the context was issued.", + "type": "string" + }, + "principal": { + "description": "Authenticated subject that originated the request.", + "type": "string" + }, + "priority": { + "description": "Advisory QoS admission-priority class this request declares (W2.4 —\nengine-native QoS lanes). One of the agent-utilities `PriorityClass`\nwire values `\"interactive\"` / `\"orchestration\"` / `\"hydration\"` /\n`\"background_ingestion\"`; any other or absent value is treated as the\norchestration default (high, never starved — matching au's\nuntagged-context semantics). Mapped to a [`crate`]-independent admission\nclass by the server's QoS scheduler (`server::qos::QosClass`).\n\nIt is **MAC-covered** (appended to `build_envelope_v2_bytes` as a\ndistinct tag-`2` optional trailer, after the tag-`1` node trailer) so a\nnoisy principal cannot forge a higher class to defeat the admission\nordering — the priority is bound to the signed envelope exactly like\nevery other claim. `#[serde(default)]` + presence-gated encoding keep an\nenvelope minted by a client that predates this claim byte-for-byte\nidentical to before (absent, not a hard error) — fully additive.", + "type": [ + "string", + "null" + ] + }, + "roles": { + "description": "Identity-provider roles asserted for policy evaluation/audit.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scopes": { + "description": "Fine-grained capabilities asserted for policy evaluation/audit.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant": { + "description": "Tenant security boundary selected by the deployment.", + "type": "string" + } + }, + "required": [ + "principal", + "tenant", + "audience", + "agent_id", + "roles", + "scopes", + "policy_version", + "delegation" + ], + "type": "object" + }, "ResetDelivery": { "additionalProperties": false, "description": "Where a self-service reset link goes (`None`: nowhere, uniformly).", diff --git a/epistemic_graph/generated/models.py b/epistemic_graph/generated/models.py index 3dcbc6211..f66c0c42f 100644 --- a/epistemic_graph/generated/models.py +++ b/epistemic_graph/generated/models.py @@ -17100,6 +17100,7 @@ class PrincipalResolution(BaseModel): mfa_enrolled: bool mfa_required: bool principal_id: str + request_context: RequestContextClaims roles: list[str] scopes: list[str] session_mfa_pending: bool | None = None From 4fa8a527f3b2e43108aacb7d04f0161547596642 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Thu, 8 Oct 2026 14:13:45 -0500 Subject: [PATCH 3/3] fix(identity): dedupe write-guard setup in resolution_context tests Extract locked_fixture_snapshot() so arbitrary_broker_lookup_refuses_ before_store_change and unauthorized_broker_cannot_use_subject_credential share the bootstrapped-state + pre-mutation snapshot setup instead of repeating it verbatim. Fixes the jscpd new-duplicate-pair gate (fragment feba492f03a1) on PR #79; merged origin/main first (already resolved the check-public-specs homelab-token failure in specs/engine-identity-and-scope/test-spec.md). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../resolution_context/tests.rs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/server/dispatch/identity_store/resolution_context/tests.rs b/src/server/dispatch/identity_store/resolution_context/tests.rs index a813b3067..bc92b479b 100644 --- a/src/server/dispatch/identity_store/resolution_context/tests.rs +++ b/src/server/dispatch/identity_store/resolution_context/tests.rs @@ -283,12 +283,23 @@ fn nonresolution_reply_does_not_request_authority() { )); } +/// Boots a fresh locked engine fixture, a broker context and a pre-mutation +/// identity-store snapshot. Shared by refusal tests that assert the store is +/// untouched after a rejected operation. +async fn locked_fixture_snapshot() -> ( + Arc>, + IdentityStore, + VerifiedRequestContext, +) { + let state = crate::server::dispatch::test_support::bootstrapped_state(); + let before = state.read().await.isolation.rbac().identity_store().clone(); + (state, before, context()) +} + #[tokio::test] async fn arbitrary_broker_lookup_refuses_before_store_change() { - let state = crate::server::dispatch::test_support::bootstrapped_state(); + let (state, before, ctx) = locked_fixture_snapshot().await; let mut state = state.write().await; - let before = state.isolation.rbac().identity_store().clone(); - let ctx = context(); let op = IdentityOp::User(UserOp::Resolve { request: ObjectRef { id: "usr:alice".into(), @@ -308,10 +319,8 @@ async fn arbitrary_broker_lookup_refuses_before_store_change() { #[tokio::test] async fn unauthorized_broker_cannot_use_subject_credential() { - let state = crate::server::dispatch::test_support::bootstrapped_state(); + let (state, before, ctx) = locked_fixture_snapshot().await; let mut state = state.write().await; - let before = state.isolation.rbac().identity_store().clone(); - let ctx = context(); let result = apply_and_compose( &mut state, &session_op(),