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