Skip to main content

agent_runtime_identity_contract/
lib.rs

1//! Stable transport-neutral contract for Runtime Workload Identity.
2//!
3//! This crate owns identity, permission and credential value types only. It
4//! intentionally contains no HTTP framework, SQL, signer or attestation code.
5
6use std::collections::BTreeMap;
7use std::fmt;
8
9pub use agent_registry_contract::ResourceRelations;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13pub const SERVICE_NAME: &str = "agent-runtime-identity";
14pub const AUDIENCE: &str = "agent-runtime-identity";
15pub const DEFAULT_PORT: u16 = 5111;
16pub const HEALTH_PATH: &str = "/health";
17pub const IDENTITIES_PATH: &str = "/internal/v1/runtime-identities";
18pub const LIST_IDENTITIES_PATH: &str = "/internal/v1/runtime-identities:list";
19pub const IDENTITY_PATH: &str = "/internal/v1/runtime-identities/{identity_id}";
20pub const ACTIVATE_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:activate";
21pub const SUSPEND_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:suspend";
22pub const RESUME_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:resume";
23pub const REVOKE_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:revoke";
24pub const PERMISSIONS_PATH: &str = "/internal/v1/runtime-identities/{identity_id}/permissions";
25pub const EFFECTIVE_PERMISSIONS_PATH: &str =
26    "/internal/v1/runtime-identities/{identity_id}/effective-permissions";
27pub const PERMISSION_CHECK_PATH: &str = "/internal/v1/runtime-permissions:check";
28pub const ISSUE_CREDENTIAL_PATH: &str = "/internal/v1/runtime-identities/credentials:issue";
29pub const RECOVER_CREDENTIAL_PATH: &str = "/internal/v1/runtime-identities/credentials:recover";
30pub const VERIFY_CREDENTIAL_PATH: &str = "/internal/v1/runtime-credentials:verify";
31pub const REVOKE_CREDENTIAL_PATH: &str = "/internal/v1/runtime-credentials:revoke";
32pub const RECOVERY_CREDENTIALS_PATH: &str =
33    "/internal/v1/runtime-identities/{identity_id}/recovery-credentials";
34pub const RECOVERY_CREDENTIAL_ACTION_PATH: &str =
35    "/internal/v1/runtime-identities/{identity_id}/recovery-credentials/{credential_action}";
36pub const JWKS_PATH: &str = "/.well-known/runtime-identity-jwks.json";
37pub const RECOVERY_CREDENTIAL_PREFIX: &str = "rti_rc_";
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct TenantProjectScope {
42    pub tenant_id: String,
43    pub project_id: String,
44}
45
46impl TenantProjectScope {
47    pub fn new(
48        tenant_id: impl Into<String>,
49        project_id: impl Into<String>,
50    ) -> Result<Self, String> {
51        let value = Self {
52            tenant_id: tenant_id.into(),
53            project_id: project_id.into(),
54        };
55        validate_identifier("tenant_id", &value.tenant_id, 128)?;
56        validate_identifier("project_id", &value.project_id, 128)?;
57        Ok(value)
58    }
59
60    pub fn local() -> Self {
61        Self {
62            tenant_id: "local".into(),
63            project_id: "default".into(),
64        }
65    }
66}
67
68macro_rules! identifier {
69    ($name:ident, $label:literal, $max:expr) => {
70        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71        #[serde(transparent)]
72        pub struct $name(String);
73
74        impl $name {
75            pub fn new(value: impl Into<String>) -> Result<Self, String> {
76                let value = value.into();
77                validate_identifier($label, &value, $max)?;
78                Ok(Self(value))
79            }
80
81            pub fn as_str(&self) -> &str {
82                &self.0
83            }
84        }
85
86        impl fmt::Display for $name {
87            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88                self.0.fmt(formatter)
89            }
90        }
91    };
92}
93
94identifier!(RuntimeIdentityId, "runtime_identity_id", 255);
95identifier!(PermissionGrantId, "permission_grant_id", 255);
96identifier!(RecoveryCredentialId, "recovery_credential_id", 255);
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
98#[serde(transparent)]
99pub struct CredentialCapability(String);
100
101impl CredentialCapability {
102    pub fn new(value: impl Into<String>) -> Result<Self, String> {
103        let value = value.into();
104        if value.is_empty() || value.len() > 128 {
105            return Err("capability must contain 1..=128 bytes".into());
106        }
107        let segments = value.split(':').collect::<Vec<_>>();
108        if segments.len() != 3
109            || segments.iter().any(|segment| {
110                segment.is_empty()
111                    || (*segment != "*"
112                        && !segment.bytes().all(|byte| {
113                            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
114                        }))
115            })
116        {
117            return Err("capability must contain three valid colon-delimited segments".into());
118        }
119        Ok(Self(value))
120    }
121
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127/// Returns whether a granted capability contains a requested capability.
128/// Wildcards match complete segments only; partial-segment wildcards are not
129/// part of the capability grammar. `*` is retained for platform principals.
130pub fn capability_grant_matches(granted: &str, requested: &str) -> bool {
131    if granted == "*" || granted == requested {
132        return true;
133    }
134
135    let mut granted_segments = granted.split(':');
136    let mut requested_segments = requested.split(':');
137    for _ in 0..3 {
138        let (Some(granted), Some(requested)) = (granted_segments.next(), requested_segments.next())
139        else {
140            return false;
141        };
142        if granted != "*" && granted != requested {
143            return false;
144        }
145    }
146    granted_segments.next().is_none() && requested_segments.next().is_none()
147}
148
149impl fmt::Display for CredentialCapability {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        self.0.fmt(formatter)
152    }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum RuntimeIdentityKind {
158    Code,
159    Evaluate,
160    Tool,
161    Custom,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum RuntimeIdentityStatus {
167    Pending,
168    Active,
169    Suspended,
170    Revoked,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", deny_unknown_fields)]
175pub struct RuntimeIdentity {
176    pub id: RuntimeIdentityId,
177    #[serde(default)]
178    pub name: String,
179    pub scope: TenantProjectScope,
180    pub kind: RuntimeIdentityKind,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub agent_id: Option<String>,
183    #[serde(default)]
184    pub relations: ResourceRelations,
185    pub status: RuntimeIdentityStatus,
186    pub revision: u64,
187    pub grant_revision: u64,
188    pub credential_generation: u64,
189    #[serde(default)]
190    pub metadata: BTreeMap<String, String>,
191    pub created_at: DateTime<Utc>,
192    pub updated_at: DateTime<Utc>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct CreateRuntimeIdentityRequest {
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub id: Option<RuntimeIdentityId>,
200    pub name: String,
201    pub kind: RuntimeIdentityKind,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub agent_id: Option<String>,
204    #[serde(default)]
205    pub relations: ResourceRelations,
206    #[serde(default)]
207    pub metadata: BTreeMap<String, String>,
208    #[serde(default)]
209    pub idempotency_key: String,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct UpdateRuntimeIdentityRequest {
215    pub expected_revision: u64,
216    pub name: String,
217    #[serde(default)]
218    pub idempotency_key: String,
219}
220
221#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct CreateRuntimeIdentityResponse {
224    pub identity: RuntimeIdentity,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub bootstrap_token: Option<String>,
227}
228
229impl fmt::Debug for CreateRuntimeIdentityResponse {
230    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231        formatter
232            .debug_struct("CreateRuntimeIdentityResponse")
233            .field("identity", &self.identity)
234            .field(
235                "bootstrap_token",
236                &self.bootstrap_token.as_ref().map(|_| "[REDACTED]"),
237            )
238            .finish()
239    }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct TransitionIdentityRequest {
245    pub expected_revision: u64,
246    pub idempotency_key: String,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub reason: Option<String>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase", deny_unknown_fields)]
253pub struct ListRuntimeIdentitiesRequest {
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub status: Option<RuntimeIdentityStatus>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub after: Option<RuntimeIdentityId>,
258    #[serde(default = "default_page_size")]
259    pub limit: usize,
260}
261
262impl Default for ListRuntimeIdentitiesRequest {
263    fn default() -> Self {
264        Self {
265            status: None,
266            after: None,
267            limit: default_page_size(),
268        }
269    }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase", deny_unknown_fields)]
274pub struct ListRuntimeIdentitiesResponse {
275    pub items: Vec<RuntimeIdentity>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub next: Option<RuntimeIdentityId>,
278}
279
280/// Who may inspect Runtime Identity records inside one tenant/project.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum IdentityAccess {
283    /// Platform admin, service token, or `*` permission: tenant-wide.
284    Tenant,
285    /// Sky / human caller: identities that list this user id.
286    User { user_id: String },
287    /// Runtime workload: only its own identity id or related runtime ids.
288    Runtime { runtime_identity_id: String },
289}
290
291impl IdentityAccess {
292    pub fn allows(&self, identity: &RuntimeIdentity) -> bool {
293        match self {
294            Self::Tenant => true,
295            Self::User { user_id } => identity.relations.user_ids.iter().any(|id| id == user_id),
296            Self::Runtime {
297                runtime_identity_id,
298            } => {
299                identity.id.as_str() == runtime_identity_id
300                    || identity
301                        .relations
302                        .runtime_ids
303                        .iter()
304                        .any(|id| id == runtime_identity_id)
305            }
306        }
307    }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct PermissionGrantInput {
313    pub audience: String,
314    pub capabilities: Vec<CredentialCapability>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub expires_at: Option<DateTime<Utc>>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase", deny_unknown_fields)]
321pub struct PermissionGrant {
322    pub id: PermissionGrantId,
323    pub identity_id: RuntimeIdentityId,
324    pub audience: String,
325    pub capabilities: Vec<CredentialCapability>,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub expires_at: Option<DateTime<Utc>>,
328    pub created_at: DateTime<Utc>,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase", deny_unknown_fields)]
333pub struct ReplacePermissionsRequest {
334    pub expected_revision: u64,
335    pub grants: Vec<PermissionGrantInput>,
336    pub idempotency_key: String,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "camelCase", deny_unknown_fields)]
341pub struct PermissionsResponse {
342    pub identity_id: RuntimeIdentityId,
343    pub revision: u64,
344    pub grants: Vec<PermissionGrant>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase", deny_unknown_fields)]
349pub struct EffectivePermissionsResponse {
350    pub identity_id: RuntimeIdentityId,
351    pub audience: String,
352    pub grant_revision: u64,
353    pub capabilities: Vec<CredentialCapability>,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase", deny_unknown_fields)]
358pub struct CheckPermissionRequest {
359    pub identity_id: RuntimeIdentityId,
360    pub audience: String,
361    pub capability: CredentialCapability,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase", deny_unknown_fields)]
366pub struct CheckPermissionResponse {
367    pub allowed: bool,
368    pub grant_revision: u64,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "kebab-case")]
373pub enum RecoveryCredentialStatus {
374    Active,
375    Revoked,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380pub struct RecoveryCredential {
381    pub id: RecoveryCredentialId,
382    pub identity_id: RuntimeIdentityId,
383    pub name: String,
384    pub purpose: String,
385    pub status: RecoveryCredentialStatus,
386    pub ceiling: BTreeMap<String, Vec<CredentialCapability>>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub predecessor_id: Option<RecoveryCredentialId>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub successor_id: Option<RecoveryCredentialId>,
391    pub created_by: String,
392    pub created_at: DateTime<Utc>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub last_used_at: Option<DateTime<Utc>>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub revoked_at: Option<DateTime<Utc>>,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase", deny_unknown_fields)]
401pub struct CreateRecoveryCredentialRequest {
402    pub name: String,
403    #[serde(default)]
404    pub purpose: String,
405    #[serde(default)]
406    pub ceiling: BTreeMap<String, Vec<CredentialCapability>>,
407    #[serde(default)]
408    pub idempotency_key: String,
409}
410
411#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase", deny_unknown_fields)]
413pub struct CreateRecoveryCredentialResponse {
414    pub credential: RecoveryCredential,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub secret: Option<String>,
417}
418
419impl fmt::Debug for CreateRecoveryCredentialResponse {
420    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
421        formatter
422            .debug_struct("CreateRecoveryCredentialResponse")
423            .field("credential", &self.credential)
424            .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
425            .finish()
426    }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase", deny_unknown_fields)]
431pub struct RotateRecoveryCredentialRequest {
432    #[serde(default)]
433    pub idempotency_key: String,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase", deny_unknown_fields)]
438pub struct RevokeRecoveryCredentialRequest {
439    #[serde(default)]
440    pub idempotency_key: String,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub reason: Option<String>,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase", deny_unknown_fields)]
447pub struct ListRecoveryCredentialsResponse {
448    pub items: Vec<RecoveryCredential>,
449}
450
451#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase", deny_unknown_fields)]
453pub struct IssueRuntimeCredentialRequest {
454    pub workload_proof: String,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub proof_audience: Option<String>,
457    pub audience: String,
458    pub capabilities: Vec<CredentialCapability>,
459}
460
461impl fmt::Debug for IssueRuntimeCredentialRequest {
462    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
463        formatter
464            .debug_struct("IssueRuntimeCredentialRequest")
465            .field("workload_proof", &"[REDACTED]")
466            .field("proof_audience", &self.proof_audience)
467            .field("audience", &self.audience)
468            .field("capabilities", &self.capabilities)
469            .finish()
470    }
471}
472
473#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(rename_all = "camelCase", deny_unknown_fields)]
475pub struct IssueRuntimeCredentialResponse {
476    pub access_token: String,
477    pub token_type: String,
478    pub expires_in_seconds: u64,
479    pub key_id: String,
480}
481
482impl fmt::Debug for IssueRuntimeCredentialResponse {
483    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
484        formatter
485            .debug_struct("IssueRuntimeCredentialResponse")
486            .field("access_token", &"[REDACTED]")
487            .field("token_type", &self.token_type)
488            .field("expires_in_seconds", &self.expires_in_seconds)
489            .field("key_id", &self.key_id)
490            .finish()
491    }
492}
493
494#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase", deny_unknown_fields)]
496pub struct VerifyRuntimeCredentialRequest {
497    pub access_token: String,
498    pub audience: String,
499}
500
501impl fmt::Debug for VerifyRuntimeCredentialRequest {
502    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
503        formatter
504            .debug_struct("VerifyRuntimeCredentialRequest")
505            .field("access_token", &"[REDACTED]")
506            .field("audience", &self.audience)
507            .finish()
508    }
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase", deny_unknown_fields)]
513pub struct RuntimeCredentialClaims {
514    pub issuer: String,
515    pub runtime_identity_id: RuntimeIdentityId,
516    pub scope: TenantProjectScope,
517    #[serde(default)]
518    pub relations: ResourceRelations,
519    pub audience: String,
520    pub capabilities: Vec<CredentialCapability>,
521    pub credential_generation: u64,
522    pub grant_revision: u64,
523    pub issued_at_seconds: u64,
524    pub expires_at_seconds: u64,
525    pub token_id: String,
526    pub key_id: String,
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "camelCase", deny_unknown_fields)]
531pub struct VerifyRuntimeCredentialResponse {
532    pub active: bool,
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub claims: Option<RuntimeCredentialClaims>,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(rename_all = "camelCase", deny_unknown_fields)]
539pub struct RevokeRuntimeCredentialRequest {
540    pub token_id: String,
541    pub expires_at_seconds: u64,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545#[serde(rename_all = "camelCase", deny_unknown_fields)]
546pub struct RuntimeCredentialPublicKey {
547    pub kty: String,
548    #[serde(rename = "use")]
549    pub key_use: String,
550    pub alg: String,
551    pub kid: String,
552    pub crv: String,
553    pub x: String,
554    pub y: String,
555}
556
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(rename_all = "camelCase", deny_unknown_fields)]
559pub struct RuntimeCredentialKeySet {
560    pub issuer: String,
561    pub keys: Vec<RuntimeCredentialPublicKey>,
562    pub cache_max_age_seconds: u64,
563}
564
565pub fn validate_audience(value: &str) -> Result<(), String> {
566    validate_identifier("audience", value, 128)
567}
568
569pub fn validate_runtime_name(value: &str) -> Result<String, String> {
570    let value = value.trim();
571    if value.is_empty() || value.chars().count() > 128 {
572        return Err("runtime name must contain 1..=128 characters".into());
573    }
574    if value.chars().any(char::is_control) {
575        return Err("runtime name must not contain control characters".into());
576    }
577    Ok(value.to_string())
578}
579
580pub fn validate_capabilities(values: &[CredentialCapability]) -> Result<(), String> {
581    if values.is_empty() || values.len() > 32 {
582        return Err("capabilities must contain 1..=32 entries".into());
583    }
584    let mut unique = std::collections::BTreeSet::new();
585    if !values.iter().all(|value| unique.insert(value.as_str())) {
586        return Err("capabilities must not contain duplicates".into());
587    }
588    Ok(())
589}
590
591fn validate_identifier(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
592    if value.is_empty() || value.len() > max_bytes {
593        return Err(format!("{label} must contain 1..={max_bytes} bytes"));
594    }
595    if !value.bytes().all(|byte| {
596        byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
597    }) {
598        return Err(format!("{label} contains unsupported characters"));
599    }
600    Ok(())
601}
602
603fn default_page_size() -> usize {
604    50
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn request_bodies_cannot_override_scope() {
613        let result = serde_json::from_value::<CreateRuntimeIdentityRequest>(serde_json::json!({
614            "name": "runtime-test",
615            "kind": "code",
616            "tenantId": "attacker"
617        }));
618        assert!(result.is_err());
619    }
620
621    #[test]
622    fn runtime_name_is_trimmed_and_bounded() {
623        assert_eq!(validate_runtime_name("  code-dev  ").unwrap(), "code-dev");
624        assert!(validate_runtime_name(" ").is_err());
625        assert!(validate_runtime_name("runtime\nname").is_err());
626        assert!(validate_runtime_name(&"r".repeat(129)).is_err());
627    }
628
629    #[test]
630    fn credentials_are_redacted_from_debug() {
631        let request = IssueRuntimeCredentialRequest {
632            workload_proof: "sensitive-bootstrap-value".into(),
633            proof_audience: None,
634            audience: "agent-model-infra".into(),
635            capabilities: vec![CredentialCapability::new("model-profile:echo:invoke").unwrap()],
636        };
637        assert!(!format!("{request:?}").contains("sensitive-bootstrap-value"));
638    }
639
640    #[test]
641    fn recovery_secrets_are_redacted_from_debug() {
642        let response = CreateRecoveryCredentialResponse {
643            credential: RecoveryCredential {
644                id: RecoveryCredentialId::new("rti_rc_abc").unwrap(),
645                identity_id: RuntimeIdentityId::new("rti_1").unwrap(),
646                name: "dev-mac".into(),
647                purpose: "local".into(),
648                status: RecoveryCredentialStatus::Active,
649                ceiling: BTreeMap::new(),
650                predecessor_id: None,
651                successor_id: None,
652                created_by: "user-1".into(),
653                created_at: Utc::now(),
654                last_used_at: None,
655                revoked_at: None,
656            },
657            secret: Some("rti_rc_abc.super-secret".into()),
658        };
659        assert!(!format!("{response:?}").contains("super-secret"));
660    }
661
662    #[test]
663    fn capabilities_are_bounded_and_unique() {
664        let capability = CredentialCapability::new("model-profile:echo:invoke").unwrap();
665        assert!(validate_capabilities(std::slice::from_ref(&capability)).is_ok());
666        assert!(validate_capabilities(&[capability.clone(), capability]).is_err());
667        assert!(CredentialCapability::new("model-profile:*:invoke").is_ok());
668        assert!(CredentialCapability::new("model-profile:echo:*").is_ok());
669        assert!(CredentialCapability::new("model-profile:echo:inv*oke").is_err());
670    }
671
672    #[test]
673    fn capability_grants_match_only_whole_segments() {
674        assert!(capability_grant_matches(
675            "model-profile:*:invoke",
676            "model-profile:deepseek:invoke"
677        ));
678        assert!(capability_grant_matches(
679            "conversation:conv-1:*",
680            "conversation:conv-1:append"
681        ));
682        assert!(!capability_grant_matches(
683            "model-profile:*:read",
684            "model-profile:deepseek:invoke"
685        ));
686        assert!(!capability_grant_matches(
687            "conversation:conv-1:*",
688            "conversation:conv-2:append"
689        ));
690        assert!(!capability_grant_matches(
691            "model-profile:*",
692            "model-profile:x"
693        ));
694    }
695
696    fn sample_identity(user_id: &str, runtime_id: &str) -> RuntimeIdentity {
697        RuntimeIdentity {
698            id: RuntimeIdentityId::new(runtime_id).unwrap(),
699            name: "code-dev".into(),
700            scope: TenantProjectScope::local(),
701            kind: RuntimeIdentityKind::Code,
702            agent_id: None,
703            relations: ResourceRelations {
704                agent_ids: Vec::new(),
705                runtime_ids: vec![runtime_id.into()],
706                user_ids: vec![user_id.into()],
707            },
708            status: RuntimeIdentityStatus::Pending,
709            revision: 1,
710            grant_revision: 0,
711            credential_generation: 1,
712            metadata: BTreeMap::new(),
713            created_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
714            updated_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
715        }
716    }
717
718    #[test]
719    fn identity_access_scopes_records_to_owner_or_runtime() {
720        let alice = sample_identity("alice", "runtime-alice");
721        assert!(IdentityAccess::Tenant.allows(&alice));
722        assert!(
723            IdentityAccess::User {
724                user_id: "alice".into()
725            }
726            .allows(&alice)
727        );
728        assert!(
729            !IdentityAccess::User {
730                user_id: "bob".into()
731            }
732            .allows(&alice)
733        );
734        assert!(
735            IdentityAccess::Runtime {
736                runtime_identity_id: "runtime-alice".into()
737            }
738            .allows(&alice)
739        );
740        assert!(
741            !IdentityAccess::Runtime {
742                runtime_identity_id: "runtime-bob".into()
743            }
744            .allows(&alice)
745        );
746    }
747}