Skip to main content

lenso_service/
delegated_context.rs

1use crate::{ContextClaimProof, DelegatedActorContext, ServiceTenancyMode, TenantContext};
2use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
3use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
4use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fs::OpenOptions,
9    io::Write as _,
10    path::Path,
11    sync::atomic::{AtomicU64, Ordering},
12    sync::{Arc, Mutex},
13};
14
15const DEVELOPMENT_ISSUER: &str = "lenso-system-sandbox-development-only";
16const CREDENTIAL_KIND: &str = "lenso.delegated-context.v1";
17const MAX_SANDBOX_CREDENTIAL_TTL_MS: u64 = 5 * 60 * 1_000;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum DelegatedContextErrorCode {
22    DevelopmentProviderForbidden,
23    InvalidRequest,
24    InvalidProof,
25    CredentialExpired,
26    AudienceMismatch,
27    DelegationRequired,
28    IntentMismatch,
29    PermissionRequired,
30    OverbroadPermissions,
31    TenantRequired,
32    TenantIncompatible,
33    PolicyRequired,
34    EvidencePersistenceFailed,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct IdentityDecisionEvidence {
40    pub outcome: String,
41    pub actor_subject: Option<String>,
42    pub delegation_id: Option<String>,
43    pub tenant_id: Option<String>,
44    pub tenant_claim_id: Option<String>,
45    pub audience: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DelegatedContextError {
50    pub code: DelegatedContextErrorCode,
51    pub message: String,
52    pub evidence: IdentityDecisionEvidence,
53}
54
55impl std::fmt::Display for DelegatedContextError {
56    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        formatter.write_str(&self.message)
58    }
59}
60
61impl std::error::Error for DelegatedContextError {}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DelegatedActorCredentialRequest {
65    pub subject: String,
66    pub audience: String,
67    pub intent: String,
68    pub permissions: Vec<String>,
69    pub issued_at_unix_ms: u64,
70    pub ttl_ms: u64,
71}
72
73impl DelegatedActorCredentialRequest {
74    #[must_use]
75    pub fn new<I, S>(
76        subject: impl Into<String>,
77        audience: impl Into<String>,
78        intent: impl Into<String>,
79        permissions: I,
80        issued_at_unix_ms: u64,
81        ttl_ms: u64,
82    ) -> Self
83    where
84        I: IntoIterator<Item = S>,
85        S: Into<String>,
86    {
87        Self {
88            subject: subject.into(),
89            audience: audience.into(),
90            intent: intent.into(),
91            permissions: permissions.into_iter().map(Into::into).collect(),
92            issued_at_unix_ms,
93            ttl_ms,
94        }
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct TenantCredentialRequest {
100    pub tenant_id: String,
101    pub actor_subject: String,
102    pub delegation_id: String,
103    pub audience: String,
104    pub issued_at_unix_ms: u64,
105    pub ttl_ms: u64,
106}
107
108impl TenantCredentialRequest {
109    #[must_use]
110    pub fn new(
111        tenant_id: impl Into<String>,
112        actor_subject: impl Into<String>,
113        delegation_id: impl Into<String>,
114        audience: impl Into<String>,
115        issued_at_unix_ms: u64,
116        ttl_ms: u64,
117    ) -> Self {
118        Self {
119            tenant_id: tenant_id.into(),
120            actor_subject: actor_subject.into(),
121            delegation_id: delegation_id.into(),
122            audience: audience.into(),
123            issued_at_unix_ms,
124            ttl_ms,
125        }
126    }
127}
128
129/// Console-side authority that creates bounded actor and tenant credentials.
130/// Target Services do not need this interface in order to verify requests.
131pub trait DelegatedContextIssuer: std::fmt::Debug + Send + Sync {
132    fn issue_actor(
133        &self,
134        request: DelegatedActorCredentialRequest,
135    ) -> Result<DelegatedActorContext, DelegatedContextError>;
136
137    fn issue_tenant(
138        &self,
139        request: TenantCredentialRequest,
140    ) -> Result<TenantContext, DelegatedContextError>;
141}
142
143/// Target-Service authority that verifies delegated credentials without
144/// receiving signing material or acquiring credential issuance capability.
145pub trait DelegatedContextVerifier: std::fmt::Debug + Send + Sync {
146    fn verify_actor(
147        &self,
148        context: &DelegatedActorContext,
149        audience: &str,
150        now_unix_ms: u64,
151    ) -> Result<(), DelegatedContextError>;
152
153    fn verify_tenant(
154        &self,
155        context: &TenantContext,
156        audience: &str,
157        now_unix_ms: u64,
158    ) -> Result<(), DelegatedContextError>;
159}
160
161/// Full provider retained for development sandboxes and Console-side
162/// composition that intentionally owns both interfaces.
163pub trait DelegatedContextProvider: DelegatedContextIssuer + DelegatedContextVerifier {}
164
165impl<T> DelegatedContextProvider for T where T: DelegatedContextIssuer + DelegatedContextVerifier {}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct ServiceContext {
170    pub actor: DelegatedActorContext,
171    pub tenant: Option<TenantContext>,
172}
173
174impl ServiceContext {
175    #[must_use]
176    pub const fn new(actor: DelegatedActorContext, tenant: Option<TenantContext>) -> Self {
177        Self { actor, tenant }
178    }
179}
180
181pub trait IdentityDecisionRecorder: std::fmt::Debug + Send + Sync {
182    fn record(&self, evidence: &IdentityDecisionEvidence) -> Result<(), String>;
183}
184
185#[derive(Debug, Default)]
186pub struct MemoryIdentityDecisionRecorder {
187    evidence: Mutex<Vec<IdentityDecisionEvidence>>,
188}
189
190impl MemoryIdentityDecisionRecorder {
191    #[must_use]
192    pub fn evidence(&self) -> Vec<IdentityDecisionEvidence> {
193        self.evidence
194            .lock()
195            .expect("evidence lock poisoned")
196            .clone()
197    }
198}
199
200impl IdentityDecisionRecorder for MemoryIdentityDecisionRecorder {
201    fn record(&self, evidence: &IdentityDecisionEvidence) -> Result<(), String> {
202        self.evidence
203            .lock()
204            .map_err(|_| "identity evidence lock poisoned".to_owned())?
205            .push(evidence.clone());
206        Ok(())
207    }
208}
209
210#[derive(Debug)]
211pub struct JsonlIdentityDecisionRecorder {
212    file: Mutex<std::fs::File>,
213}
214
215impl JsonlIdentityDecisionRecorder {
216    pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
217        OpenOptions::new()
218            .create(true)
219            .append(true)
220            .open(path)
221            .map(|file| Self {
222                file: Mutex::new(file),
223            })
224    }
225}
226
227impl IdentityDecisionRecorder for JsonlIdentityDecisionRecorder {
228    fn record(&self, evidence: &IdentityDecisionEvidence) -> Result<(), String> {
229        let mut line = serde_json::to_vec(evidence).map_err(|error| error.to_string())?;
230        line.push(b'\n');
231        self.file
232            .lock()
233            .map_err(|_| "identity evidence journal lock poisoned".to_owned())?
234            .write_all(&line)
235            .map_err(|error| error.to_string())
236    }
237}
238
239#[derive(Debug, Clone)]
240pub struct ServiceContextAdmission {
241    provider: Arc<dyn DelegatedContextVerifier>,
242    policies: std::collections::BTreeMap<String, ServiceContextPolicy>,
243}
244
245impl ServiceContextAdmission {
246    #[must_use]
247    pub fn new<I, S>(provider: Arc<dyn DelegatedContextVerifier>, policies: I) -> Self
248    where
249        I: IntoIterator<Item = (S, ServiceContextPolicy)>,
250        S: Into<String>,
251    {
252        Self {
253            provider,
254            policies: policies
255                .into_iter()
256                .map(|(boundary, policy)| (boundary.into(), policy))
257                .collect(),
258        }
259    }
260
261    pub fn admit(
262        &self,
263        boundary: &str,
264        context: Option<&ServiceContext>,
265        now_unix_ms: u64,
266    ) -> Result<AuthenticatedServiceContext, DelegatedContextError> {
267        let policy = self.policies.get(boundary).ok_or_else(|| {
268            bare_error(
269                DelegatedContextErrorCode::PolicyRequired,
270                "Service Context policy is required for this boundary",
271                boundary,
272            )
273        })?;
274        policy.verify(
275            self.provider.as_ref(),
276            context.map(|item| &item.actor),
277            context.and_then(|item| item.tenant.as_ref()),
278            now_unix_ms,
279        )
280    }
281
282    pub fn invalid_proof(&self, boundary: &str) -> DelegatedContextError {
283        let audience = self
284            .policies
285            .get(boundary)
286            .map_or(boundary, |policy| policy.audience.as_str());
287        invalid_proof(audience)
288    }
289
290    #[must_use]
291    pub fn tenancy_mode(&self, boundary: &str) -> Option<ServiceTenancyMode> {
292        self.policies
293            .get(boundary)
294            .map(|policy| policy.tenancy_mode.clone())
295    }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct ServiceContextPolicy {
300    pub audience: String,
301    pub intent: String,
302    pub required_permissions: BTreeSet<String>,
303    pub allowed_permissions: BTreeSet<String>,
304    pub tenancy_mode: ServiceTenancyMode,
305}
306
307impl ServiceContextPolicy {
308    #[must_use]
309    pub fn new<R, RS, A, AS>(
310        audience: impl Into<String>,
311        intent: impl Into<String>,
312        required_permissions: R,
313        allowed_permissions: A,
314        tenancy_mode: ServiceTenancyMode,
315    ) -> Self
316    where
317        R: IntoIterator<Item = RS>,
318        RS: Into<String>,
319        A: IntoIterator<Item = AS>,
320        AS: Into<String>,
321    {
322        Self {
323            audience: audience.into(),
324            intent: intent.into(),
325            required_permissions: required_permissions.into_iter().map(Into::into).collect(),
326            allowed_permissions: allowed_permissions.into_iter().map(Into::into).collect(),
327            tenancy_mode,
328        }
329    }
330
331    pub fn verify(
332        &self,
333        provider: &dyn DelegatedContextVerifier,
334        actor: Option<&DelegatedActorContext>,
335        tenant: Option<&TenantContext>,
336        now_unix_ms: u64,
337    ) -> Result<AuthenticatedServiceContext, DelegatedContextError> {
338        let actor = actor.ok_or_else(|| {
339            self.error(
340                DelegatedContextErrorCode::DelegationRequired,
341                "Delegated Actor Context is required",
342                None,
343                tenant,
344            )
345        })?;
346        provider.verify_actor(actor, &self.audience, now_unix_ms)?;
347        if actor.intent != self.intent {
348            return Err(self.error(
349                DelegatedContextErrorCode::IntentMismatch,
350                "delegated intent is not authorized for this operation",
351                Some(actor),
352                tenant,
353            ));
354        }
355        let permissions = actor.permissions.iter().cloned().collect::<BTreeSet<_>>();
356        if !self.required_permissions.is_subset(&permissions) {
357            return Err(self.error(
358                DelegatedContextErrorCode::PermissionRequired,
359                "delegation does not grant every required permission",
360                Some(actor),
361                tenant,
362            ));
363        }
364        if !permissions.is_subset(&self.allowed_permissions) {
365            return Err(self.error(
366                DelegatedContextErrorCode::OverbroadPermissions,
367                "delegation contains permissions broader than this operation allows",
368                Some(actor),
369                tenant,
370            ));
371        }
372
373        let tenant = match (&self.tenancy_mode, tenant) {
374            (ServiceTenancyMode::Required, None) => {
375                return Err(self.error(
376                    DelegatedContextErrorCode::TenantRequired,
377                    "Tenant Context is required",
378                    Some(actor),
379                    None,
380                ));
381            }
382            (ServiceTenancyMode::None, Some(_)) => {
383                return Err(self.error(
384                    DelegatedContextErrorCode::TenantIncompatible,
385                    "Tenant Context is incompatible with none tenancy mode",
386                    Some(actor),
387                    None,
388                ));
389            }
390            (_, Some(tenant)) => {
391                provider.verify_tenant(tenant, &self.audience, now_unix_ms)?;
392                if tenant.issuer != actor.issuer
393                    || tenant.expires_at_unix_ms > actor.expires_at_unix_ms
394                    || tenant.actor_subject != actor.subject
395                    || tenant.delegation_id != actor.delegation_id
396                {
397                    return Err(self.error(
398                        DelegatedContextErrorCode::TenantIncompatible,
399                        "Tenant Context must be bound to this actor delegation and cannot outlive it",
400                        Some(actor),
401                        None,
402                    ));
403                }
404                Some(tenant.clone())
405            }
406            (_, None) => None,
407        };
408
409        Ok(AuthenticatedServiceContext {
410            actor: actor.clone(),
411            tenant: tenant.clone(),
412            evidence: IdentityDecisionEvidence {
413                outcome: "identity_context_accepted".to_owned(),
414                actor_subject: Some(actor.subject.clone()),
415                delegation_id: Some(actor.delegation_id.clone()),
416                tenant_id: tenant.as_ref().map(|item| item.tenant_id.clone()),
417                tenant_claim_id: tenant.as_ref().map(|item| item.claim_id.clone()),
418                audience: self.audience.clone(),
419            },
420        })
421    }
422
423    fn error(
424        &self,
425        code: DelegatedContextErrorCode,
426        message: impl Into<String>,
427        actor: Option<&DelegatedActorContext>,
428        tenant: Option<&TenantContext>,
429    ) -> DelegatedContextError {
430        DelegatedContextError {
431            code,
432            message: message.into(),
433            evidence: IdentityDecisionEvidence {
434                outcome: error_outcome(code).to_owned(),
435                actor_subject: actor.map(|item| item.subject.clone()),
436                delegation_id: actor.map(|item| item.delegation_id.clone()),
437                tenant_id: tenant.map(|item| item.tenant_id.clone()),
438                tenant_claim_id: tenant.map(|item| item.claim_id.clone()),
439                audience: self.audience.clone(),
440            },
441        }
442    }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase")]
447pub struct AuthenticatedServiceContext {
448    pub actor: DelegatedActorContext,
449    pub tenant: Option<TenantContext>,
450    pub evidence: IdentityDecisionEvidence,
451}
452
453const ED25519_DELEGATED_CONTEXT_ALGORITHM: &str = "Ed25519";
454
455#[derive(Serialize)]
456#[serde(rename_all = "camelCase")]
457struct ActorSigningPayload<'a> {
458    protocol: &'static str,
459    issuer: &'a str,
460    subject: &'a str,
461    audiences: &'a [String],
462    intent: &'a str,
463    permissions: &'a [String],
464    expires_at_unix_ms: u64,
465    delegation_id: &'a str,
466}
467
468#[derive(Serialize)]
469#[serde(rename_all = "camelCase")]
470struct TenantSigningPayload<'a> {
471    protocol: &'static str,
472    issuer: &'a str,
473    tenant_id: &'a str,
474    actor_subject: &'a str,
475    delegation_id: &'a str,
476    audiences: &'a [String],
477    expires_at_unix_ms: u64,
478    claim_id: &'a str,
479}
480
481/// Canonical, domain-separated bytes signed by a Console authority for an
482/// Actor Context. The proof itself is excluded from the payload.
483pub fn delegated_actor_signing_bytes(
484    context: &DelegatedActorContext,
485) -> serde_json::Result<Vec<u8>> {
486    lenso_contracts::canonical_json(&ActorSigningPayload {
487        protocol: "lenso.delegated-actor-context.ed25519.v1",
488        issuer: &context.issuer,
489        subject: &context.subject,
490        audiences: &context.audiences,
491        intent: &context.intent,
492        permissions: &context.permissions,
493        expires_at_unix_ms: context.expires_at_unix_ms,
494        delegation_id: &context.delegation_id,
495    })
496}
497
498/// Canonical, domain-separated bytes signed by a Console authority for a
499/// Tenant Context. Actor and tenant signatures cannot be exchanged.
500pub fn tenant_context_signing_bytes(context: &TenantContext) -> serde_json::Result<Vec<u8>> {
501    lenso_contracts::canonical_json(&TenantSigningPayload {
502        protocol: "lenso.tenant-context.ed25519.v1",
503        issuer: &context.issuer,
504        tenant_id: &context.tenant_id,
505        actor_subject: &context.actor_subject,
506        delegation_id: &context.delegation_id,
507        audiences: &context.audiences,
508        expires_at_unix_ms: context.expires_at_unix_ms,
509        claim_id: &context.claim_id,
510    })
511}
512
513/// Verify-only production adapter backed by operator-configured Ed25519 public
514/// keys. Keys are addressed by `(issuer, verification method)`, allowing
515/// overlapping rotation without accepting an issuer's key under another name.
516#[derive(Debug, Clone, Default)]
517pub struct Ed25519DelegatedContextVerifier {
518    keys: BTreeMap<(String, String), VerifyingKey>,
519}
520
521impl Ed25519DelegatedContextVerifier {
522    pub fn from_base64_public_keys<I, IS, MS, KS>(keys: I) -> Result<Self, DelegatedContextError>
523    where
524        I: IntoIterator<Item = (IS, MS, KS)>,
525        IS: Into<String>,
526        MS: Into<String>,
527        KS: AsRef<str>,
528    {
529        let mut parsed = BTreeMap::new();
530        for (issuer, method, encoded) in keys {
531            let issuer = issuer.into();
532            let method = method.into();
533            if issuer.trim().is_empty() || method.trim().is_empty() {
534                return Err(bare_error(
535                    DelegatedContextErrorCode::InvalidRequest,
536                    "delegated context issuer and verification method are required",
537                    "",
538                ));
539            }
540            let bytes = URL_SAFE_NO_PAD.decode(encoded.as_ref()).map_err(|_| {
541                bare_error(
542                    DelegatedContextErrorCode::InvalidRequest,
543                    "delegated context Ed25519 public key encoding is invalid",
544                    "",
545                )
546            })?;
547            let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
548                bare_error(
549                    DelegatedContextErrorCode::InvalidRequest,
550                    "delegated context Ed25519 public keys must contain exactly 32 bytes",
551                    "",
552                )
553            })?;
554            let key = VerifyingKey::from_bytes(&bytes).map_err(|_| {
555                bare_error(
556                    DelegatedContextErrorCode::InvalidRequest,
557                    "delegated context Ed25519 public key is invalid",
558                    "",
559                )
560            })?;
561            if parsed.insert((issuer, method), key).is_some() {
562                return Err(bare_error(
563                    DelegatedContextErrorCode::InvalidRequest,
564                    "delegated context issuer and verification method must be unique",
565                    "",
566                ));
567            }
568        }
569        if parsed.is_empty() {
570            return Err(bare_error(
571                DelegatedContextErrorCode::InvalidRequest,
572                "at least one delegated context verification key is required",
573                "",
574            ));
575        }
576        Ok(Self { keys: parsed })
577    }
578
579    fn verify(
580        &self,
581        issuer: &str,
582        proof: &ContextClaimProof,
583        payload: &[u8],
584        audience: &str,
585    ) -> Result<(), DelegatedContextError> {
586        if proof.algorithm != ED25519_DELEGATED_CONTEXT_ALGORITHM {
587            return Err(invalid_proof(audience));
588        }
589        let key = self
590            .keys
591            .get(&(issuer.to_owned(), proof.verification_method.clone()))
592            .ok_or_else(|| invalid_proof(audience))?;
593        let signature = URL_SAFE_NO_PAD
594            .decode(&proof.signature)
595            .ok()
596            .and_then(|bytes| Signature::from_slice(&bytes).ok())
597            .ok_or_else(|| invalid_proof(audience))?;
598        key.verify(payload, &signature)
599            .map_err(|_| invalid_proof(audience))
600    }
601}
602
603impl DelegatedContextVerifier for Ed25519DelegatedContextVerifier {
604    fn verify_actor(
605        &self,
606        context: &DelegatedActorContext,
607        audience: &str,
608        now_unix_ms: u64,
609    ) -> Result<(), DelegatedContextError> {
610        let payload =
611            delegated_actor_signing_bytes(context).map_err(|_| invalid_proof(audience))?;
612        self.verify(&context.issuer, &context.proof, &payload, audience)?;
613        verify_audience_and_expiry(
614            &context.audiences,
615            context.expires_at_unix_ms,
616            audience,
617            now_unix_ms,
618        )
619    }
620
621    fn verify_tenant(
622        &self,
623        context: &TenantContext,
624        audience: &str,
625        now_unix_ms: u64,
626    ) -> Result<(), DelegatedContextError> {
627        let payload = tenant_context_signing_bytes(context).map_err(|_| invalid_proof(audience))?;
628        self.verify(&context.issuer, &context.proof, &payload, audience)?;
629        verify_audience_and_expiry(
630            &context.audiences,
631            context.expires_at_unix_ms,
632            audience,
633            now_unix_ms,
634        )
635    }
636}
637
638fn verify_audience_and_expiry(
639    audiences: &[String],
640    expires_at_unix_ms: u64,
641    audience: &str,
642    now_unix_ms: u64,
643) -> Result<(), DelegatedContextError> {
644    if audiences.len() != 1 || audiences[0] != audience {
645        return Err(bare_error(
646            DelegatedContextErrorCode::AudienceMismatch,
647            "delegated context is not intended for this audience",
648            audience,
649        ));
650    }
651    if expires_at_unix_ms <= now_unix_ms {
652        return Err(bare_error(
653            DelegatedContextErrorCode::CredentialExpired,
654            "delegated context has expired",
655            audience,
656        ));
657    }
658    Ok(())
659}
660
661#[derive(Debug)]
662pub struct SystemSandboxDelegatedContextProvider {
663    secret: String,
664    next_actor: AtomicU64,
665    next_tenant: AtomicU64,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
669struct DelegatedClaims {
670    iss: String,
671    sub: String,
672    aud: Vec<String>,
673    exp: u64,
674    iat: u64,
675    jti: String,
676    credential_kind: String,
677    claim_kind: String,
678    intent: Option<String>,
679    permissions: Vec<String>,
680    tenant_id: Option<String>,
681    actor_subject: Option<String>,
682    delegation_id: Option<String>,
683    expires_at_unix_ms: u64,
684}
685
686impl SystemSandboxDelegatedContextProvider {
687    pub fn new(
688        environment: &str,
689        secret: impl Into<String>,
690    ) -> Result<Self, DelegatedContextError> {
691        if !matches!(environment, "local" | "development" | "test") {
692            return Err(bare_error(
693                DelegatedContextErrorCode::DevelopmentProviderForbidden,
694                "sandbox delegated context provider is development-only",
695                "",
696            ));
697        }
698        let secret = secret.into();
699        if secret.trim().is_empty() {
700            return Err(bare_error(
701                DelegatedContextErrorCode::InvalidRequest,
702                "sandbox delegated context secret is required",
703                "",
704            ));
705        }
706        Ok(Self {
707            secret,
708            next_actor: AtomicU64::new(1),
709            next_tenant: AtomicU64::new(1),
710        })
711    }
712
713    fn issue(
714        &self,
715        mut claims: DelegatedClaims,
716    ) -> Result<(DelegatedClaims, ContextClaimProof), DelegatedContextError> {
717        validate_issue_claims(&claims)?;
718        claims.exp = claims.expires_at_unix_ms.div_ceil(1_000);
719        claims.iat /= 1_000;
720        let mut header = Header::new(Algorithm::HS256);
721        header.kid = Some("sandbox-delegated-context-1".to_owned());
722        let token = encode(
723            &header,
724            &claims,
725            &EncodingKey::from_secret(self.secret.as_bytes()),
726        )
727        .map_err(|_| invalid_proof(&claims.aud[0]))?;
728        Ok((
729            claims,
730            ContextClaimProof {
731                verification_method: "sandbox-delegated-context-1".to_owned(),
732                algorithm: "HS256".to_owned(),
733                signature: token,
734            },
735        ))
736    }
737
738    fn verify(
739        &self,
740        token: &str,
741        audience: &str,
742        now_unix_ms: u64,
743        expected_kind: &str,
744    ) -> Result<DelegatedClaims, DelegatedContextError> {
745        let mut validation = Validation::new(Algorithm::HS256);
746        validation.validate_exp = false;
747        validation.validate_aud = false;
748        validation.required_spec_claims.clear();
749        let claims = decode::<DelegatedClaims>(
750            token,
751            &DecodingKey::from_secret(self.secret.as_bytes()),
752            &validation,
753        )
754        .map_err(|_| invalid_proof(audience))?
755        .claims;
756        if claims.credential_kind != CREDENTIAL_KIND || claims.claim_kind != expected_kind {
757            return Err(invalid_proof(audience));
758        }
759        if !claims.aud.iter().any(|item| item == audience) {
760            return Err(bare_error(
761                DelegatedContextErrorCode::AudienceMismatch,
762                "delegated context is not intended for this audience",
763                audience,
764            ));
765        }
766        if claims.expires_at_unix_ms <= now_unix_ms {
767            return Err(bare_error(
768                DelegatedContextErrorCode::CredentialExpired,
769                "delegated context has expired",
770                audience,
771            ));
772        }
773        Ok(claims)
774    }
775}
776
777impl DelegatedContextIssuer for SystemSandboxDelegatedContextProvider {
778    fn issue_actor(
779        &self,
780        request: DelegatedActorCredentialRequest,
781    ) -> Result<DelegatedActorContext, DelegatedContextError> {
782        let delegation_id = format!(
783            "delegation_{}",
784            self.next_actor.fetch_add(1, Ordering::SeqCst)
785        );
786        let expires_at_unix_ms = request.issued_at_unix_ms.saturating_add(request.ttl_ms);
787        let (claims, proof) = self.issue(DelegatedClaims {
788            iss: DEVELOPMENT_ISSUER.to_owned(),
789            sub: request.subject,
790            aud: vec![request.audience],
791            exp: 0,
792            iat: request.issued_at_unix_ms,
793            jti: delegation_id.clone(),
794            credential_kind: CREDENTIAL_KIND.to_owned(),
795            claim_kind: "actor".to_owned(),
796            intent: Some(request.intent),
797            permissions: request.permissions,
798            tenant_id: None,
799            actor_subject: None,
800            delegation_id: None,
801            expires_at_unix_ms,
802        })?;
803        Ok(DelegatedActorContext {
804            issuer: claims.iss,
805            subject: claims.sub,
806            audiences: claims.aud,
807            intent: claims.intent.expect("actor claims include intent"),
808            permissions: claims.permissions,
809            expires_at_unix_ms: claims.expires_at_unix_ms,
810            delegation_id,
811            proof,
812        })
813    }
814
815    fn issue_tenant(
816        &self,
817        request: TenantCredentialRequest,
818    ) -> Result<TenantContext, DelegatedContextError> {
819        let claim_id = format!(
820            "tenant_claim_{}",
821            self.next_tenant.fetch_add(1, Ordering::SeqCst)
822        );
823        let expires_at_unix_ms = request.issued_at_unix_ms.saturating_add(request.ttl_ms);
824        let (claims, proof) = self.issue(DelegatedClaims {
825            iss: DEVELOPMENT_ISSUER.to_owned(),
826            sub: request.actor_subject.clone(),
827            aud: vec![request.audience],
828            exp: 0,
829            iat: request.issued_at_unix_ms,
830            jti: claim_id.clone(),
831            credential_kind: CREDENTIAL_KIND.to_owned(),
832            claim_kind: "tenant".to_owned(),
833            intent: None,
834            permissions: Vec::new(),
835            tenant_id: Some(request.tenant_id),
836            actor_subject: Some(request.actor_subject),
837            delegation_id: Some(request.delegation_id),
838            expires_at_unix_ms,
839        })?;
840        Ok(TenantContext {
841            issuer: claims.iss,
842            tenant_id: claims.tenant_id.expect("tenant claims include tenant id"),
843            actor_subject: claims
844                .actor_subject
845                .expect("tenant claims include actor subject"),
846            delegation_id: claims
847                .delegation_id
848                .expect("tenant claims include delegation id"),
849            audiences: claims.aud,
850            expires_at_unix_ms: claims.expires_at_unix_ms,
851            claim_id,
852            proof,
853        })
854    }
855}
856
857impl DelegatedContextVerifier for SystemSandboxDelegatedContextProvider {
858    fn verify_actor(
859        &self,
860        context: &DelegatedActorContext,
861        audience: &str,
862        now_unix_ms: u64,
863    ) -> Result<(), DelegatedContextError> {
864        let claims = self.verify(&context.proof.signature, audience, now_unix_ms, "actor")?;
865        if context.proof.algorithm != "HS256"
866            || context.proof.verification_method != "sandbox-delegated-context-1"
867            || context.issuer != claims.iss
868            || context.subject != claims.sub
869            || context.audiences != claims.aud
870            || context.intent != claims.intent.as_deref().unwrap_or_default()
871            || context.permissions != claims.permissions
872            || context.expires_at_unix_ms != claims.expires_at_unix_ms
873            || context.delegation_id != claims.jti
874        {
875            return Err(invalid_proof(audience));
876        }
877        Ok(())
878    }
879
880    fn verify_tenant(
881        &self,
882        context: &TenantContext,
883        audience: &str,
884        now_unix_ms: u64,
885    ) -> Result<(), DelegatedContextError> {
886        let claims = self.verify(&context.proof.signature, audience, now_unix_ms, "tenant")?;
887        if context.proof.algorithm != "HS256"
888            || context.proof.verification_method != "sandbox-delegated-context-1"
889            || context.issuer != claims.iss
890            || Some(context.tenant_id.as_str()) != claims.tenant_id.as_deref()
891            || Some(context.actor_subject.as_str()) != claims.actor_subject.as_deref()
892            || Some(context.delegation_id.as_str()) != claims.delegation_id.as_deref()
893            || context.audiences != claims.aud
894            || context.expires_at_unix_ms != claims.expires_at_unix_ms
895            || context.claim_id != claims.jti
896        {
897            return Err(invalid_proof(audience));
898        }
899        Ok(())
900    }
901}
902
903fn validate_issue_claims(claims: &DelegatedClaims) -> Result<(), DelegatedContextError> {
904    let ttl_ms = claims.expires_at_unix_ms.saturating_sub(claims.iat);
905    let actor_invalid = claims.claim_kind == "actor"
906        && (claims.intent.as_deref().is_none_or(str::is_empty)
907            || claims.permissions.is_empty()
908            || claims.permissions.iter().any(String::is_empty));
909    if claims.sub.trim().is_empty()
910        || claims.aud.len() != 1
911        || claims.aud[0].trim().is_empty()
912        || ttl_ms == 0
913        || ttl_ms > MAX_SANDBOX_CREDENTIAL_TTL_MS
914        || actor_invalid
915        || (claims.claim_kind == "tenant"
916            && (claims.tenant_id.as_deref().is_none_or(str::is_empty)
917                || claims.actor_subject.as_deref().is_none_or(str::is_empty)
918                || claims.delegation_id.as_deref().is_none_or(str::is_empty)))
919    {
920        return Err(bare_error(
921            DelegatedContextErrorCode::InvalidRequest,
922            "delegated context request must be bounded and non-empty",
923            claims.aud.first().map_or("", String::as_str),
924        ));
925    }
926    Ok(())
927}
928
929fn invalid_proof(audience: &str) -> DelegatedContextError {
930    bare_error(
931        DelegatedContextErrorCode::InvalidProof,
932        "delegated context proof is invalid",
933        audience,
934    )
935}
936
937fn bare_error(
938    code: DelegatedContextErrorCode,
939    message: impl Into<String>,
940    audience: &str,
941) -> DelegatedContextError {
942    DelegatedContextError {
943        code,
944        message: message.into(),
945        evidence: IdentityDecisionEvidence {
946            outcome: error_outcome(code).to_owned(),
947            actor_subject: None,
948            delegation_id: None,
949            tenant_id: None,
950            tenant_claim_id: None,
951            audience: audience.to_owned(),
952        },
953    }
954}
955
956const fn error_outcome(code: DelegatedContextErrorCode) -> &'static str {
957    match code {
958        DelegatedContextErrorCode::DevelopmentProviderForbidden => "development_provider_forbidden",
959        DelegatedContextErrorCode::InvalidRequest => "delegated_context_invalid_request",
960        DelegatedContextErrorCode::InvalidProof => "delegated_context_invalid_proof",
961        DelegatedContextErrorCode::CredentialExpired => "delegated_context_expired",
962        DelegatedContextErrorCode::AudienceMismatch => "delegated_context_audience_mismatch",
963        DelegatedContextErrorCode::DelegationRequired => "delegation_required",
964        DelegatedContextErrorCode::IntentMismatch => "delegated_intent_mismatch",
965        DelegatedContextErrorCode::PermissionRequired => "delegated_permission_required",
966        DelegatedContextErrorCode::OverbroadPermissions => "delegated_permissions_overbroad",
967        DelegatedContextErrorCode::TenantRequired => "tenant_context_required",
968        DelegatedContextErrorCode::TenantIncompatible => "tenant_context_incompatible",
969        DelegatedContextErrorCode::PolicyRequired => "service_context_policy_required",
970        DelegatedContextErrorCode::EvidencePersistenceFailed => {
971            "identity_evidence_persistence_failed"
972        }
973    }
974}