1use 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}