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