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
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12pub const SERVICE_NAME: &str = "agent-runtime-identity";
13pub const AUDIENCE: &str = "agent-runtime-identity";
14pub const DEFAULT_PORT: u16 = 5111;
15pub const HEALTH_PATH: &str = "/health";
16pub const IDENTITIES_PATH: &str = "/internal/v1/runtime-identities";
17pub const LIST_IDENTITIES_PATH: &str = "/internal/v1/runtime-identities:list";
18pub const IDENTITY_PATH: &str = "/internal/v1/runtime-identities/{identity_id}";
19pub const ACTIVATE_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:activate";
20pub const SUSPEND_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:suspend";
21pub const RESUME_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:resume";
22pub const REVOKE_PATH: &str = "/internal/v1/runtime-identities/{identity_id}:revoke";
23pub const PERMISSIONS_PATH: &str = "/internal/v1/runtime-identities/{identity_id}/permissions";
24pub const EFFECTIVE_PERMISSIONS_PATH: &str =
25    "/internal/v1/runtime-identities/{identity_id}/effective-permissions";
26pub const PERMISSION_CHECK_PATH: &str = "/internal/v1/runtime-permissions:check";
27pub const ISSUE_CREDENTIAL_PATH: &str = "/internal/v1/runtime-identities/credentials:issue";
28pub const VERIFY_CREDENTIAL_PATH: &str = "/internal/v1/runtime-credentials:verify";
29pub const REVOKE_CREDENTIAL_PATH: &str = "/internal/v1/runtime-credentials:revoke";
30pub const JWKS_PATH: &str = "/.well-known/runtime-identity-jwks.json";
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct TenantProjectScope {
35    pub tenant_id: String,
36    pub project_id: String,
37}
38
39impl TenantProjectScope {
40    pub fn new(
41        tenant_id: impl Into<String>,
42        project_id: impl Into<String>,
43    ) -> Result<Self, String> {
44        let value = Self {
45            tenant_id: tenant_id.into(),
46            project_id: project_id.into(),
47        };
48        validate_identifier("tenant_id", &value.tenant_id, 128)?;
49        validate_identifier("project_id", &value.project_id, 128)?;
50        Ok(value)
51    }
52
53    pub fn local() -> Self {
54        Self {
55            tenant_id: "local".into(),
56            project_id: "default".into(),
57        }
58    }
59}
60
61macro_rules! identifier {
62    ($name:ident, $label:literal, $max:expr) => {
63        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64        #[serde(transparent)]
65        pub struct $name(String);
66
67        impl $name {
68            pub fn new(value: impl Into<String>) -> Result<Self, String> {
69                let value = value.into();
70                validate_identifier($label, &value, $max)?;
71                Ok(Self(value))
72            }
73
74            pub fn as_str(&self) -> &str {
75                &self.0
76            }
77        }
78
79        impl fmt::Display for $name {
80            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81                self.0.fmt(formatter)
82            }
83        }
84    };
85}
86
87identifier!(RuntimeIdentityId, "runtime_identity_id", 255);
88identifier!(PermissionGrantId, "permission_grant_id", 255);
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
90#[serde(transparent)]
91pub struct CredentialCapability(String);
92
93impl CredentialCapability {
94    pub fn new(value: impl Into<String>) -> Result<Self, String> {
95        let value = value.into();
96        if value.is_empty() || value.len() > 128 {
97            return Err("capability must contain 1..=128 bytes".into());
98        }
99        let segments = value.split(':').collect::<Vec<_>>();
100        if segments.len() != 3
101            || segments.iter().any(|segment| {
102                segment.is_empty()
103                    || (*segment != "*"
104                        && !segment.bytes().all(|byte| {
105                            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
106                        }))
107            })
108        {
109            return Err("capability must contain three valid colon-delimited segments".into());
110        }
111        Ok(Self(value))
112    }
113
114    pub fn as_str(&self) -> &str {
115        &self.0
116    }
117}
118
119impl fmt::Display for CredentialCapability {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        self.0.fmt(formatter)
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum RuntimeIdentityKind {
128    Code,
129    Evaluate,
130    Tool,
131    Custom,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum RuntimeIdentityStatus {
137    Pending,
138    Active,
139    Suspended,
140    Revoked,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct RuntimeIdentity {
146    pub id: RuntimeIdentityId,
147    pub scope: TenantProjectScope,
148    pub kind: RuntimeIdentityKind,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub agent_id: Option<String>,
151    pub status: RuntimeIdentityStatus,
152    pub revision: u64,
153    pub grant_revision: u64,
154    pub credential_generation: u64,
155    #[serde(default)]
156    pub metadata: BTreeMap<String, String>,
157    pub created_at: DateTime<Utc>,
158    pub updated_at: DateTime<Utc>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct CreateRuntimeIdentityRequest {
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub id: Option<RuntimeIdentityId>,
166    pub kind: RuntimeIdentityKind,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub agent_id: Option<String>,
169    #[serde(default)]
170    pub metadata: BTreeMap<String, String>,
171    pub idempotency_key: String,
172}
173
174#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct CreateRuntimeIdentityResponse {
177    pub identity: RuntimeIdentity,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub bootstrap_token: Option<String>,
180}
181
182impl fmt::Debug for CreateRuntimeIdentityResponse {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter
185            .debug_struct("CreateRuntimeIdentityResponse")
186            .field("identity", &self.identity)
187            .field(
188                "bootstrap_token",
189                &self.bootstrap_token.as_ref().map(|_| "[REDACTED]"),
190            )
191            .finish()
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct TransitionIdentityRequest {
198    pub expected_revision: u64,
199    pub idempotency_key: String,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub reason: Option<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase", deny_unknown_fields)]
206pub struct ListRuntimeIdentitiesRequest {
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub status: Option<RuntimeIdentityStatus>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub after: Option<RuntimeIdentityId>,
211    #[serde(default = "default_page_size")]
212    pub limit: usize,
213}
214
215impl Default for ListRuntimeIdentitiesRequest {
216    fn default() -> Self {
217        Self {
218            status: None,
219            after: None,
220            limit: default_page_size(),
221        }
222    }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase", deny_unknown_fields)]
227pub struct ListRuntimeIdentitiesResponse {
228    pub items: Vec<RuntimeIdentity>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub next: Option<RuntimeIdentityId>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235pub struct PermissionGrantInput {
236    pub audience: String,
237    pub capabilities: Vec<CredentialCapability>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub expires_at: Option<DateTime<Utc>>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct PermissionGrant {
245    pub id: PermissionGrantId,
246    pub identity_id: RuntimeIdentityId,
247    pub audience: String,
248    pub capabilities: Vec<CredentialCapability>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub expires_at: Option<DateTime<Utc>>,
251    pub created_at: DateTime<Utc>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase", deny_unknown_fields)]
256pub struct ReplacePermissionsRequest {
257    pub expected_revision: u64,
258    pub grants: Vec<PermissionGrantInput>,
259    pub idempotency_key: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase", deny_unknown_fields)]
264pub struct PermissionsResponse {
265    pub identity_id: RuntimeIdentityId,
266    pub revision: u64,
267    pub grants: Vec<PermissionGrant>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase", deny_unknown_fields)]
272pub struct EffectivePermissionsResponse {
273    pub identity_id: RuntimeIdentityId,
274    pub audience: String,
275    pub grant_revision: u64,
276    pub capabilities: Vec<CredentialCapability>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase", deny_unknown_fields)]
281pub struct CheckPermissionRequest {
282    pub identity_id: RuntimeIdentityId,
283    pub audience: String,
284    pub capability: CredentialCapability,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase", deny_unknown_fields)]
289pub struct CheckPermissionResponse {
290    pub allowed: bool,
291    pub grant_revision: u64,
292}
293
294#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase", deny_unknown_fields)]
296pub struct IssueRuntimeCredentialRequest {
297    pub workload_proof: String,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub proof_audience: Option<String>,
300    pub audience: String,
301    pub capabilities: Vec<CredentialCapability>,
302}
303
304impl fmt::Debug for IssueRuntimeCredentialRequest {
305    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306        formatter
307            .debug_struct("IssueRuntimeCredentialRequest")
308            .field("workload_proof", &"[REDACTED]")
309            .field("proof_audience", &self.proof_audience)
310            .field("audience", &self.audience)
311            .field("capabilities", &self.capabilities)
312            .finish()
313    }
314}
315
316#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct IssueRuntimeCredentialResponse {
319    pub access_token: String,
320    pub token_type: String,
321    pub expires_in_seconds: u64,
322    pub key_id: String,
323}
324
325impl fmt::Debug for IssueRuntimeCredentialResponse {
326    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
327        formatter
328            .debug_struct("IssueRuntimeCredentialResponse")
329            .field("access_token", &"[REDACTED]")
330            .field("token_type", &self.token_type)
331            .field("expires_in_seconds", &self.expires_in_seconds)
332            .field("key_id", &self.key_id)
333            .finish()
334    }
335}
336
337#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase", deny_unknown_fields)]
339pub struct VerifyRuntimeCredentialRequest {
340    pub access_token: String,
341    pub audience: String,
342}
343
344impl fmt::Debug for VerifyRuntimeCredentialRequest {
345    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346        formatter
347            .debug_struct("VerifyRuntimeCredentialRequest")
348            .field("access_token", &"[REDACTED]")
349            .field("audience", &self.audience)
350            .finish()
351    }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356pub struct RuntimeCredentialClaims {
357    pub issuer: String,
358    pub runtime_identity_id: RuntimeIdentityId,
359    pub scope: TenantProjectScope,
360    pub audience: String,
361    pub capabilities: Vec<CredentialCapability>,
362    pub credential_generation: u64,
363    pub grant_revision: u64,
364    pub issued_at_seconds: u64,
365    pub expires_at_seconds: u64,
366    pub token_id: String,
367    pub key_id: String,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct VerifyRuntimeCredentialResponse {
373    pub active: bool,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub claims: Option<RuntimeCredentialClaims>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380pub struct RevokeRuntimeCredentialRequest {
381    pub token_id: String,
382    pub expires_at_seconds: u64,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "camelCase", deny_unknown_fields)]
387pub struct RuntimeCredentialPublicKey {
388    pub kty: String,
389    #[serde(rename = "use")]
390    pub key_use: String,
391    pub alg: String,
392    pub kid: String,
393    pub crv: String,
394    pub x: String,
395    pub y: String,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "camelCase", deny_unknown_fields)]
400pub struct RuntimeCredentialKeySet {
401    pub issuer: String,
402    pub keys: Vec<RuntimeCredentialPublicKey>,
403    pub cache_max_age_seconds: u64,
404}
405
406pub fn validate_audience(value: &str) -> Result<(), String> {
407    validate_identifier("audience", value, 128)
408}
409
410pub fn validate_capabilities(values: &[CredentialCapability]) -> Result<(), String> {
411    if values.is_empty() || values.len() > 32 {
412        return Err("capabilities must contain 1..=32 entries".into());
413    }
414    let mut unique = std::collections::BTreeSet::new();
415    if !values.iter().all(|value| unique.insert(value.as_str())) {
416        return Err("capabilities must not contain duplicates".into());
417    }
418    Ok(())
419}
420
421fn validate_identifier(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
422    if value.is_empty() || value.len() > max_bytes {
423        return Err(format!("{label} must contain 1..={max_bytes} bytes"));
424    }
425    if !value.bytes().all(|byte| {
426        byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
427    }) {
428        return Err(format!("{label} contains unsupported characters"));
429    }
430    Ok(())
431}
432
433fn default_page_size() -> usize {
434    50
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn request_bodies_cannot_override_scope() {
443        let result = serde_json::from_value::<CreateRuntimeIdentityRequest>(serde_json::json!({
444            "kind": "code",
445            "tenantId": "attacker"
446        }));
447        assert!(result.is_err());
448    }
449
450    #[test]
451    fn credentials_are_redacted_from_debug() {
452        let request = IssueRuntimeCredentialRequest {
453            workload_proof: "sensitive-bootstrap-value".into(),
454            proof_audience: None,
455            audience: "agent-model-infra".into(),
456            capabilities: vec![CredentialCapability::new("model-profile:echo:invoke").unwrap()],
457        };
458        assert!(!format!("{request:?}").contains("sensitive-bootstrap-value"));
459    }
460
461    #[test]
462    fn capabilities_are_bounded_and_unique() {
463        let capability = CredentialCapability::new("model-profile:echo:invoke").unwrap();
464        assert!(validate_capabilities(std::slice::from_ref(&capability)).is_ok());
465        assert!(validate_capabilities(&[capability.clone(), capability]).is_err());
466        assert!(CredentialCapability::new("model-profile:*:invoke").is_ok());
467        assert!(CredentialCapability::new("model-profile:echo:*").is_ok());
468        assert!(CredentialCapability::new("model-profile:echo:inv*oke").is_err());
469    }
470}