1use 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
120pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct PermissionGrantInput {
276 pub audience: String,
277 pub capabilities: Vec<CredentialCapability>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub expires_at: Option<DateTime<Utc>>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase", deny_unknown_fields)]
284pub struct PermissionGrant {
285 pub id: PermissionGrantId,
286 pub identity_id: RuntimeIdentityId,
287 pub audience: String,
288 pub capabilities: Vec<CredentialCapability>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub expires_at: Option<DateTime<Utc>>,
291 pub created_at: DateTime<Utc>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase", deny_unknown_fields)]
296pub struct ReplacePermissionsRequest {
297 pub expected_revision: u64,
298 pub grants: Vec<PermissionGrantInput>,
299 pub idempotency_key: String,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "camelCase", deny_unknown_fields)]
304pub struct PermissionsResponse {
305 pub identity_id: RuntimeIdentityId,
306 pub revision: u64,
307 pub grants: Vec<PermissionGrant>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct EffectivePermissionsResponse {
313 pub identity_id: RuntimeIdentityId,
314 pub audience: String,
315 pub grant_revision: u64,
316 pub capabilities: Vec<CredentialCapability>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase", deny_unknown_fields)]
321pub struct CheckPermissionRequest {
322 pub identity_id: RuntimeIdentityId,
323 pub audience: String,
324 pub capability: CredentialCapability,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase", deny_unknown_fields)]
329pub struct CheckPermissionResponse {
330 pub allowed: bool,
331 pub grant_revision: u64,
332}
333
334#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase", deny_unknown_fields)]
336pub struct IssueRuntimeCredentialRequest {
337 pub workload_proof: String,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub proof_audience: Option<String>,
340 pub audience: String,
341 pub capabilities: Vec<CredentialCapability>,
342}
343
344impl fmt::Debug for IssueRuntimeCredentialRequest {
345 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346 formatter
347 .debug_struct("IssueRuntimeCredentialRequest")
348 .field("workload_proof", &"[REDACTED]")
349 .field("proof_audience", &self.proof_audience)
350 .field("audience", &self.audience)
351 .field("capabilities", &self.capabilities)
352 .finish()
353 }
354}
355
356#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase", deny_unknown_fields)]
358pub struct IssueRuntimeCredentialResponse {
359 pub access_token: String,
360 pub token_type: String,
361 pub expires_in_seconds: u64,
362 pub key_id: String,
363}
364
365impl fmt::Debug for IssueRuntimeCredentialResponse {
366 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
367 formatter
368 .debug_struct("IssueRuntimeCredentialResponse")
369 .field("access_token", &"[REDACTED]")
370 .field("token_type", &self.token_type)
371 .field("expires_in_seconds", &self.expires_in_seconds)
372 .field("key_id", &self.key_id)
373 .finish()
374 }
375}
376
377#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase", deny_unknown_fields)]
379pub struct VerifyRuntimeCredentialRequest {
380 pub access_token: String,
381 pub audience: String,
382}
383
384impl fmt::Debug for VerifyRuntimeCredentialRequest {
385 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
386 formatter
387 .debug_struct("VerifyRuntimeCredentialRequest")
388 .field("access_token", &"[REDACTED]")
389 .field("audience", &self.audience)
390 .finish()
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase", deny_unknown_fields)]
396pub struct RuntimeCredentialClaims {
397 pub issuer: String,
398 pub runtime_identity_id: RuntimeIdentityId,
399 pub scope: TenantProjectScope,
400 #[serde(default)]
401 pub relations: ResourceRelations,
402 pub audience: String,
403 pub capabilities: Vec<CredentialCapability>,
404 pub credential_generation: u64,
405 pub grant_revision: u64,
406 pub issued_at_seconds: u64,
407 pub expires_at_seconds: u64,
408 pub token_id: String,
409 pub key_id: String,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase", deny_unknown_fields)]
414pub struct VerifyRuntimeCredentialResponse {
415 pub active: bool,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub claims: Option<RuntimeCredentialClaims>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase", deny_unknown_fields)]
422pub struct RevokeRuntimeCredentialRequest {
423 pub token_id: String,
424 pub expires_at_seconds: u64,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase", deny_unknown_fields)]
429pub struct RuntimeCredentialPublicKey {
430 pub kty: String,
431 #[serde(rename = "use")]
432 pub key_use: String,
433 pub alg: String,
434 pub kid: String,
435 pub crv: String,
436 pub x: String,
437 pub y: String,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase", deny_unknown_fields)]
442pub struct RuntimeCredentialKeySet {
443 pub issuer: String,
444 pub keys: Vec<RuntimeCredentialPublicKey>,
445 pub cache_max_age_seconds: u64,
446}
447
448pub fn validate_audience(value: &str) -> Result<(), String> {
449 validate_identifier("audience", value, 128)
450}
451
452pub fn validate_runtime_name(value: &str) -> Result<String, String> {
453 let value = value.trim();
454 if value.is_empty() || value.chars().count() > 128 {
455 return Err("runtime name must contain 1..=128 characters".into());
456 }
457 if value.chars().any(char::is_control) {
458 return Err("runtime name must not contain control characters".into());
459 }
460 Ok(value.to_string())
461}
462
463pub fn validate_capabilities(values: &[CredentialCapability]) -> Result<(), String> {
464 if values.is_empty() || values.len() > 32 {
465 return Err("capabilities must contain 1..=32 entries".into());
466 }
467 let mut unique = std::collections::BTreeSet::new();
468 if !values.iter().all(|value| unique.insert(value.as_str())) {
469 return Err("capabilities must not contain duplicates".into());
470 }
471 Ok(())
472}
473
474fn validate_identifier(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
475 if value.is_empty() || value.len() > max_bytes {
476 return Err(format!("{label} must contain 1..={max_bytes} bytes"));
477 }
478 if !value.bytes().all(|byte| {
479 byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'-' | b'_' | b'@')
480 }) {
481 return Err(format!("{label} contains unsupported characters"));
482 }
483 Ok(())
484}
485
486fn default_page_size() -> usize {
487 50
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn request_bodies_cannot_override_scope() {
496 let result = serde_json::from_value::<CreateRuntimeIdentityRequest>(serde_json::json!({
497 "name": "runtime-test",
498 "kind": "code",
499 "tenantId": "attacker"
500 }));
501 assert!(result.is_err());
502 }
503
504 #[test]
505 fn runtime_name_is_trimmed_and_bounded() {
506 assert_eq!(validate_runtime_name(" code-dev ").unwrap(), "code-dev");
507 assert!(validate_runtime_name(" ").is_err());
508 assert!(validate_runtime_name("runtime\nname").is_err());
509 assert!(validate_runtime_name(&"r".repeat(129)).is_err());
510 }
511
512 #[test]
513 fn credentials_are_redacted_from_debug() {
514 let request = IssueRuntimeCredentialRequest {
515 workload_proof: "sensitive-bootstrap-value".into(),
516 proof_audience: None,
517 audience: "agent-model-infra".into(),
518 capabilities: vec![CredentialCapability::new("model-profile:echo:invoke").unwrap()],
519 };
520 assert!(!format!("{request:?}").contains("sensitive-bootstrap-value"));
521 }
522
523 #[test]
524 fn capabilities_are_bounded_and_unique() {
525 let capability = CredentialCapability::new("model-profile:echo:invoke").unwrap();
526 assert!(validate_capabilities(std::slice::from_ref(&capability)).is_ok());
527 assert!(validate_capabilities(&[capability.clone(), capability]).is_err());
528 assert!(CredentialCapability::new("model-profile:*:invoke").is_ok());
529 assert!(CredentialCapability::new("model-profile:echo:*").is_ok());
530 assert!(CredentialCapability::new("model-profile:echo:inv*oke").is_err());
531 }
532
533 #[test]
534 fn capability_grants_match_only_whole_segments() {
535 assert!(capability_grant_matches(
536 "model-profile:*:invoke",
537 "model-profile:deepseek:invoke"
538 ));
539 assert!(capability_grant_matches(
540 "conversation:conv-1:*",
541 "conversation:conv-1:append"
542 ));
543 assert!(!capability_grant_matches(
544 "model-profile:*:read",
545 "model-profile:deepseek:invoke"
546 ));
547 assert!(!capability_grant_matches(
548 "conversation:conv-1:*",
549 "conversation:conv-2:append"
550 ));
551 assert!(!capability_grant_matches(
552 "model-profile:*",
553 "model-profile:x"
554 ));
555 }
556}