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
120impl fmt::Display for CredentialCapability {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        self.0.fmt(formatter)
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum RuntimeIdentityKind {
129    Code,
130    Evaluate,
131    Tool,
132    Custom,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum RuntimeIdentityStatus {
138    Pending,
139    Active,
140    Suspended,
141    Revoked,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct RuntimeIdentity {
147    pub id: RuntimeIdentityId,
148    pub scope: TenantProjectScope,
149    pub kind: RuntimeIdentityKind,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub agent_id: Option<String>,
152    #[serde(default)]
153    pub relations: ResourceRelations,
154    pub status: RuntimeIdentityStatus,
155    pub revision: u64,
156    pub grant_revision: u64,
157    pub credential_generation: u64,
158    #[serde(default)]
159    pub metadata: BTreeMap<String, String>,
160    pub created_at: DateTime<Utc>,
161    pub updated_at: DateTime<Utc>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct CreateRuntimeIdentityRequest {
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub id: Option<RuntimeIdentityId>,
169    pub kind: RuntimeIdentityKind,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub agent_id: Option<String>,
172    #[serde(default)]
173    pub relations: ResourceRelations,
174    #[serde(default)]
175    pub metadata: BTreeMap<String, String>,
176    pub idempotency_key: String,
177}
178
179#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct CreateRuntimeIdentityResponse {
182    pub identity: RuntimeIdentity,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub bootstrap_token: Option<String>,
185}
186
187impl fmt::Debug for CreateRuntimeIdentityResponse {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        formatter
190            .debug_struct("CreateRuntimeIdentityResponse")
191            .field("identity", &self.identity)
192            .field(
193                "bootstrap_token",
194                &self.bootstrap_token.as_ref().map(|_| "[REDACTED]"),
195            )
196            .finish()
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase", deny_unknown_fields)]
202pub struct TransitionIdentityRequest {
203    pub expected_revision: u64,
204    pub idempotency_key: String,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub reason: Option<String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211pub struct ListRuntimeIdentitiesRequest {
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub status: Option<RuntimeIdentityStatus>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub after: Option<RuntimeIdentityId>,
216    #[serde(default = "default_page_size")]
217    pub limit: usize,
218}
219
220impl Default for ListRuntimeIdentitiesRequest {
221    fn default() -> Self {
222        Self {
223            status: None,
224            after: None,
225            limit: default_page_size(),
226        }
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase", deny_unknown_fields)]
232pub struct ListRuntimeIdentitiesResponse {
233    pub items: Vec<RuntimeIdentity>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub next: Option<RuntimeIdentityId>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase", deny_unknown_fields)]
240pub struct PermissionGrantInput {
241    pub audience: String,
242    pub capabilities: Vec<CredentialCapability>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub expires_at: Option<DateTime<Utc>>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase", deny_unknown_fields)]
249pub struct PermissionGrant {
250    pub id: PermissionGrantId,
251    pub identity_id: RuntimeIdentityId,
252    pub audience: String,
253    pub capabilities: Vec<CredentialCapability>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub expires_at: Option<DateTime<Utc>>,
256    pub created_at: DateTime<Utc>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261pub struct ReplacePermissionsRequest {
262    pub expected_revision: u64,
263    pub grants: Vec<PermissionGrantInput>,
264    pub idempotency_key: String,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "camelCase", deny_unknown_fields)]
269pub struct PermissionsResponse {
270    pub identity_id: RuntimeIdentityId,
271    pub revision: u64,
272    pub grants: Vec<PermissionGrant>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct EffectivePermissionsResponse {
278    pub identity_id: RuntimeIdentityId,
279    pub audience: String,
280    pub grant_revision: u64,
281    pub capabilities: Vec<CredentialCapability>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct CheckPermissionRequest {
287    pub identity_id: RuntimeIdentityId,
288    pub audience: String,
289    pub capability: CredentialCapability,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase", deny_unknown_fields)]
294pub struct CheckPermissionResponse {
295    pub allowed: bool,
296    pub grant_revision: u64,
297}
298
299#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase", deny_unknown_fields)]
301pub struct IssueRuntimeCredentialRequest {
302    pub workload_proof: String,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub proof_audience: Option<String>,
305    pub audience: String,
306    pub capabilities: Vec<CredentialCapability>,
307}
308
309impl fmt::Debug for IssueRuntimeCredentialRequest {
310    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
311        formatter
312            .debug_struct("IssueRuntimeCredentialRequest")
313            .field("workload_proof", &"[REDACTED]")
314            .field("proof_audience", &self.proof_audience)
315            .field("audience", &self.audience)
316            .field("capabilities", &self.capabilities)
317            .finish()
318    }
319}
320
321#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase", deny_unknown_fields)]
323pub struct IssueRuntimeCredentialResponse {
324    pub access_token: String,
325    pub token_type: String,
326    pub expires_in_seconds: u64,
327    pub key_id: String,
328}
329
330impl fmt::Debug for IssueRuntimeCredentialResponse {
331    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
332        formatter
333            .debug_struct("IssueRuntimeCredentialResponse")
334            .field("access_token", &"[REDACTED]")
335            .field("token_type", &self.token_type)
336            .field("expires_in_seconds", &self.expires_in_seconds)
337            .field("key_id", &self.key_id)
338            .finish()
339    }
340}
341
342#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct VerifyRuntimeCredentialRequest {
345    pub access_token: String,
346    pub audience: String,
347}
348
349impl fmt::Debug for VerifyRuntimeCredentialRequest {
350    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351        formatter
352            .debug_struct("VerifyRuntimeCredentialRequest")
353            .field("access_token", &"[REDACTED]")
354            .field("audience", &self.audience)
355            .finish()
356    }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "camelCase", deny_unknown_fields)]
361pub struct RuntimeCredentialClaims {
362    pub issuer: String,
363    pub runtime_identity_id: RuntimeIdentityId,
364    pub scope: TenantProjectScope,
365    #[serde(default)]
366    pub relations: ResourceRelations,
367    pub audience: String,
368    pub capabilities: Vec<CredentialCapability>,
369    pub credential_generation: u64,
370    pub grant_revision: u64,
371    pub issued_at_seconds: u64,
372    pub expires_at_seconds: u64,
373    pub token_id: String,
374    pub key_id: String,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase", deny_unknown_fields)]
379pub struct VerifyRuntimeCredentialResponse {
380    pub active: bool,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub claims: Option<RuntimeCredentialClaims>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "camelCase", deny_unknown_fields)]
387pub struct RevokeRuntimeCredentialRequest {
388    pub token_id: String,
389    pub expires_at_seconds: u64,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase", deny_unknown_fields)]
394pub struct RuntimeCredentialPublicKey {
395    pub kty: String,
396    #[serde(rename = "use")]
397    pub key_use: String,
398    pub alg: String,
399    pub kid: String,
400    pub crv: String,
401    pub x: String,
402    pub y: String,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase", deny_unknown_fields)]
407pub struct RuntimeCredentialKeySet {
408    pub issuer: String,
409    pub keys: Vec<RuntimeCredentialPublicKey>,
410    pub cache_max_age_seconds: u64,
411}
412
413pub fn validate_audience(value: &str) -> Result<(), String> {
414    validate_identifier("audience", value, 128)
415}
416
417pub fn validate_capabilities(values: &[CredentialCapability]) -> Result<(), String> {
418    if values.is_empty() || values.len() > 32 {
419        return Err("capabilities must contain 1..=32 entries".into());
420    }
421    let mut unique = std::collections::BTreeSet::new();
422    if !values.iter().all(|value| unique.insert(value.as_str())) {
423        return Err("capabilities must not contain duplicates".into());
424    }
425    Ok(())
426}
427
428fn validate_identifier(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
429    if value.is_empty() || value.len() > max_bytes {
430        return Err(format!("{label} must contain 1..={max_bytes} bytes"));
431    }
432    if !value.bytes().all(|byte| {
433        byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
434    }) {
435        return Err(format!("{label} contains unsupported characters"));
436    }
437    Ok(())
438}
439
440fn default_page_size() -> usize {
441    50
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn request_bodies_cannot_override_scope() {
450        let result = serde_json::from_value::<CreateRuntimeIdentityRequest>(serde_json::json!({
451            "kind": "code",
452            "tenantId": "attacker"
453        }));
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn credentials_are_redacted_from_debug() {
459        let request = IssueRuntimeCredentialRequest {
460            workload_proof: "sensitive-bootstrap-value".into(),
461            proof_audience: None,
462            audience: "agent-model-infra".into(),
463            capabilities: vec![CredentialCapability::new("model-profile:echo:invoke").unwrap()],
464        };
465        assert!(!format!("{request:?}").contains("sensitive-bootstrap-value"));
466    }
467
468    #[test]
469    fn capabilities_are_bounded_and_unique() {
470        let capability = CredentialCapability::new("model-profile:echo:invoke").unwrap();
471        assert!(validate_capabilities(std::slice::from_ref(&capability)).is_ok());
472        assert!(validate_capabilities(&[capability.clone(), capability]).is_err());
473        assert!(CredentialCapability::new("model-profile:*:invoke").is_ok());
474        assert!(CredentialCapability::new("model-profile:echo:*").is_ok());
475        assert!(CredentialCapability::new("model-profile:echo:inv*oke").is_err());
476    }
477}