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