1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10use crate::error::{Result, SdkError};
11use crate::identity::{verify_envelope, AgentId, Envelope, Event};
12use url::Url;
13
14pub const PROTOCOL: &str = "agent-delegation/1.0";
15pub const DELEGATION_GRANT: &str = "delegation.grant";
16pub const DELEGATION_REVOKE: &str = "delegation.revoke";
17
18#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum DelegationStatus {
21 Active,
22 Suspended,
23 Expired,
24 Revoked,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
28pub struct PrincipalLink {
29 pub name: String,
30 pub url: String,
31 pub rel: String,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
35pub struct PrincipalDescriptor {
36 pub id: String,
37 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
38 pub kind: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub name: Option<String>,
41}
42
43impl PrincipalDescriptor {
44 pub fn new(id: impl Into<String>) -> Self {
45 Self {
46 id: id.into(),
47 kind: None,
48 name: None,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
54pub struct PrincipalDocument {
55 pub id: String,
56 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
57 pub kind: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub name: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub description: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub avatar_url: Option<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub links: Vec<PrincipalLink>,
66 pub controllers: Vec<AgentId>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub delegations_url: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub updated_at: Option<i64>,
71 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub extra: BTreeMap<String, Value>,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub struct DelegationGrantPayload {
77 pub id: String,
78 pub principal: PrincipalDescriptor,
79 pub subject: AgentId,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub relationship: Option<String>,
82 pub scopes: Vec<String>,
83 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84 pub constraints: BTreeMap<String, Value>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub not_before: Option<i64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub expires_at: Option<i64>,
89}
90
91impl DelegationGrantPayload {
92 pub fn new(
93 id: impl Into<String>,
94 principal: PrincipalDescriptor,
95 subject: AgentId,
96 scopes: Vec<String>,
97 ) -> Self {
98 Self {
99 id: id.into(),
100 principal,
101 subject,
102 relationship: None,
103 scopes,
104 constraints: BTreeMap::new(),
105 not_before: None,
106 expires_at: None,
107 }
108 }
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub struct DelegationRevokePayload {
113 pub id: String,
114 pub principal_id: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub reason: Option<String>,
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
120#[serde(untagged)]
121pub enum DelegationPayload {
122 Grant(DelegationGrantPayload),
123 Revoke(DelegationRevokePayload),
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
127pub struct DelegationCredential {
128 pub id: String,
129 pub protocol: String,
130 pub principal: PrincipalDescriptor,
131 pub controller: AgentId,
132 pub subject: AgentId,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub relationship: Option<String>,
135 pub scopes: Vec<String>,
136 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
137 pub constraints: BTreeMap<String, Value>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub not_before: Option<i64>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub expires_at: Option<i64>,
142 pub status: DelegationStatus,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub status_url: Option<String>,
145 pub updated_at: i64,
146 pub event_id: String,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
150pub struct DelegationStatusDocument {
151 pub id: String,
152 pub status: DelegationStatus,
153 pub checked_at: i64,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub expires_at: Option<i64>,
156 pub event_id: String,
157}
158
159#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
160pub struct DelegationServiceDiscovery {
161 pub protocol: String,
162 pub service: String,
163 pub endpoints: DelegationServiceEndpoints,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub features: Vec<String>,
166}
167
168#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
169pub struct DelegationServiceEndpoints {
170 pub delegations: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub query: Option<String>,
173}
174
175#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
176pub struct DelegationQueryRequest {
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub subject: Option<AgentId>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub principal_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub status: Option<DelegationStatus>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub limit: Option<u32>,
185}
186
187#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
188pub struct DelegationSummary {
189 pub id: String,
190 pub subject: AgentId,
191 pub principal: PrincipalDescriptor,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub scopes: Vec<String>,
194 pub status: DelegationStatus,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub status_url: Option<String>,
197}
198
199#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
200pub struct DelegationQueryResponse {
201 pub result: Vec<DelegationSummary>,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
205pub struct DelegationEventsResponse {
206 pub result: Vec<Envelope<DelegationPayload>>,
207}
208
209pub fn delegation_grant_event(
210 actor: AgentId,
211 created_at: i64,
212 nonce: u64,
213 payload: DelegationGrantPayload,
214) -> Event<DelegationGrantPayload> {
215 Event::new(
216 PROTOCOL,
217 DELEGATION_GRANT,
218 actor,
219 created_at,
220 nonce,
221 payload,
222 )
223}
224
225pub fn delegation_revoke_event(
226 actor: AgentId,
227 created_at: i64,
228 nonce: u64,
229 payload: DelegationRevokePayload,
230) -> Event<DelegationRevokePayload> {
231 Event::new(
232 PROTOCOL,
233 DELEGATION_REVOKE,
234 actor,
235 created_at,
236 nonce,
237 payload,
238 )
239}
240
241pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
242 validate_https_url(&document.id, "principal.id")?;
243 if document.controllers.is_empty() {
244 return Err(SdkError::InvalidPayload(
245 "principal document controllers must be non-empty".to_owned(),
246 ));
247 }
248 for controller in &document.controllers {
249 controller.public_key_bytes()?;
250 }
251 if let Some(url) = &document.delegations_url {
252 validate_https_url(url, "delegations_url")?;
253 }
254 Ok(())
255}
256
257pub fn validate_delegation_grant_payload(
258 payload: &DelegationGrantPayload,
259 created_at: Option<i64>,
260) -> Result<()> {
261 validate_non_empty(&payload.id, "payload.id")?;
262 validate_principal_descriptor(&payload.principal)?;
263 payload.subject.public_key_bytes()?;
264 if payload.scopes.is_empty() || payload.scopes.iter().any(|scope| scope.trim().is_empty()) {
265 return Err(SdkError::InvalidPayload(
266 "delegation scopes must be non-empty".to_owned(),
267 ));
268 }
269 if let Some(expires_at) = payload.expires_at {
270 if matches!(payload.not_before, Some(not_before) if expires_at <= not_before) {
271 return Err(SdkError::InvalidPayload(
272 "expires_at must be greater than not_before".to_owned(),
273 ));
274 }
275 if matches!(created_at, Some(created_at) if expires_at <= created_at) {
276 return Err(SdkError::InvalidPayload(
277 "expires_at must be greater than created_at".to_owned(),
278 ));
279 }
280 }
281 Ok(())
282}
283
284pub fn validate_delegation_query_request(request: &DelegationQueryRequest) -> Result<()> {
287 if request.subject.is_none() && request.principal_id.is_none() {
288 return Err(SdkError::InvalidPayload(
289 "query must include at least one of subject or principal_id".to_owned(),
290 ));
291 }
292 if let Some(subject) = &request.subject {
293 subject.public_key_bytes()?;
294 }
295 if let Some(principal_id) = &request.principal_id {
296 validate_https_url(principal_id, "principal_id")?;
297 }
298 if matches!(request.limit, Some(0)) {
299 return Err(SdkError::InvalidPayload(
300 "limit must be a positive integer".to_owned(),
301 ));
302 }
303 Ok(())
304}
305
306pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
307 validate_non_empty(&payload.id, "payload.id")?;
308 validate_https_url(&payload.principal_id, "principal_id")
309}
310
311pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
312 verify_envelope(envelope)?;
313 if envelope.event.protocol != PROTOCOL {
314 return Err(SdkError::InvalidEventProtocol {
315 expected: PROTOCOL.to_owned(),
316 actual: envelope.event.protocol.clone(),
317 });
318 }
319 match (&envelope.event.kind[..], &envelope.event.payload) {
320 (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
321 validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
322 }
323 (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
324 validate_delegation_revoke_payload(payload)
325 }
326 (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
327 "delegation event type does not match payload shape".to_owned(),
328 )),
329 _ => Err(SdkError::InvalidEventType {
330 expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
331 actual: envelope.event.kind.clone(),
332 }),
333 }
334}
335
336pub fn materialize_delegation_credential(
337 envelope: &Envelope<DelegationGrantPayload>,
338 status: DelegationStatus,
339 status_url: Option<String>,
340 updated_at: Option<i64>,
341) -> Result<DelegationCredential> {
342 verify_envelope(envelope)?;
343 if envelope.event.protocol != PROTOCOL {
344 return Err(SdkError::InvalidEventProtocol {
345 expected: PROTOCOL.to_owned(),
346 actual: envelope.event.protocol.clone(),
347 });
348 }
349 if envelope.event.kind != DELEGATION_GRANT {
350 return Err(SdkError::InvalidEventType {
351 expected: DELEGATION_GRANT.to_owned(),
352 actual: envelope.event.kind.clone(),
353 });
354 }
355 validate_delegation_grant_payload(&envelope.event.payload, Some(envelope.event.created_at))?;
356 let payload = &envelope.event.payload;
357 Ok(DelegationCredential {
358 id: payload.id.clone(),
359 protocol: PROTOCOL.to_owned(),
360 principal: payload.principal.clone(),
361 controller: envelope.event.actor.clone(),
362 subject: payload.subject.clone(),
363 relationship: payload.relationship.clone(),
364 scopes: payload.scopes.clone(),
365 constraints: payload.constraints.clone(),
366 not_before: payload.not_before,
367 expires_at: payload.expires_at,
368 status,
369 status_url,
370 updated_at: updated_at.unwrap_or(envelope.event.created_at),
371 event_id: envelope.hash.clone(),
372 })
373}
374
375fn validate_principal_descriptor(principal: &PrincipalDescriptor) -> Result<()> {
376 validate_https_url(&principal.id, "principal.id")
377}
378
379fn validate_https_url(value: &str, field: &str) -> Result<()> {
380 let parsed = Url::parse(value)
381 .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
382 if parsed.scheme() == "https" && parsed.host_str().is_some() {
383 Ok(())
384 } else {
385 Err(SdkError::InvalidPayload(format!(
386 "{field} must be an HTTPS URL"
387 )))
388 }
389}
390
391fn validate_non_empty(value: &str, field: &str) -> Result<()> {
392 if value.trim().is_empty() {
393 Err(SdkError::InvalidPayload(format!(
394 "{field} must not be empty"
395 )))
396 } else {
397 Ok(())
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::identity::AgentSigner;
405
406 #[test]
407 fn validates_and_materializes_grant() {
408 let controller = AgentSigner::from_seed([31; 32]);
409 let subject = AgentSigner::from_seed([32; 32]);
410 let mut payload = DelegationGrantPayload::new(
411 "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
412 PrincipalDescriptor {
413 id: "https://al.ink/yan".to_owned(),
414 kind: Some("person".to_owned()),
415 name: Some("Yan".to_owned()),
416 },
417 subject.agent_id(),
418 vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
419 );
420 payload.relationship = Some("primary_delegate".to_owned());
421 payload.not_before = Some(1_779_753_600_000);
422 payload.expires_at = Some(1_790_000_000_000);
423 let envelope = controller
424 .sign_event(delegation_grant_event(
425 controller.agent_id(),
426 1_779_753_600_000,
427 1,
428 payload,
429 ))
430 .unwrap();
431 let envelope_value = serde_json::to_value(&envelope).unwrap();
432 let envelope_for_validation: Envelope<DelegationPayload> =
433 serde_json::from_value(envelope_value).unwrap();
434
435 validate_delegation_envelope(&envelope_for_validation).unwrap();
436 let credential = materialize_delegation_credential(
437 &envelope,
438 DelegationStatus::Active,
439 Some("https://al.ink/v1/delegations/del/status".to_owned()),
440 None,
441 )
442 .unwrap();
443
444 assert_eq!(credential.protocol, PROTOCOL);
445 assert_eq!(credential.controller, controller.agent_id());
446 assert_eq!(credential.subject, subject.agent_id());
447 assert_eq!(credential.event_id, envelope.hash);
448 }
449
450 #[test]
451 fn validates_revoke_and_rejects_invalid_grants() {
452 let controller = AgentSigner::from_seed([33; 32]);
453 let envelope = controller
454 .sign_event(delegation_revoke_event(
455 controller.agent_id(),
456 1_779_753_700_000,
457 2,
458 DelegationRevokePayload {
459 id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
460 principal_id: "https://al.ink/yan".to_owned(),
461 reason: Some("rotated_primary_agent".to_owned()),
462 },
463 ))
464 .unwrap();
465 let envelope: Envelope<DelegationPayload> =
466 serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
467 validate_delegation_envelope(&envelope).unwrap();
468
469 let invalid = DelegationGrantPayload::new(
470 "del",
471 PrincipalDescriptor::new("http://example.com"),
472 controller.agent_id(),
473 Vec::new(),
474 );
475 assert!(validate_delegation_grant_payload(&invalid, None).is_err());
476 }
477
478 #[test]
479 fn grant_expiry_checked_against_not_before_and_created_at() {
480 let controller = AgentSigner::from_seed([36; 32]);
481 let base = DelegationGrantPayload::new(
482 "del_1",
483 PrincipalDescriptor::new("https://al.ink/yan"),
484 controller.agent_id(),
485 vec!["inbox.screen".to_owned()],
486 );
487
488 let mut same_as_not_before = base.clone();
489 same_as_not_before.not_before = Some(2000);
490 same_as_not_before.expires_at = Some(2000);
491 assert!(validate_delegation_grant_payload(&same_as_not_before, Some(1000)).is_err());
492
493 let mut before_created_at = base.clone();
494 before_created_at.not_before = Some(500);
495 before_created_at.expires_at = Some(800);
496 assert!(validate_delegation_grant_payload(&before_created_at, Some(1000)).is_err());
497
498 let mut valid = base;
499 valid.not_before = Some(500);
500 valid.expires_at = Some(1500);
501 validate_delegation_grant_payload(&valid, Some(1000)).unwrap();
502 }
503
504 #[test]
505 fn delegation_queries_require_subject_or_principal() {
506 let subject = AgentSigner::from_seed([37; 32]).agent_id();
507 validate_delegation_query_request(&DelegationQueryRequest {
508 subject: Some(subject.clone()),
509 ..DelegationQueryRequest::default()
510 })
511 .unwrap();
512 validate_delegation_query_request(&DelegationQueryRequest {
513 principal_id: Some("https://al.ink/yan".to_owned()),
514 limit: Some(20),
515 ..DelegationQueryRequest::default()
516 })
517 .unwrap();
518 assert!(validate_delegation_query_request(&DelegationQueryRequest::default()).is_err());
519 assert!(validate_delegation_query_request(&DelegationQueryRequest {
520 subject: Some(subject),
521 limit: Some(0),
522 ..DelegationQueryRequest::default()
523 })
524 .is_err());
525 }
526
527 #[test]
528 fn validates_principal_document() {
529 let controller = AgentSigner::from_seed([34; 32]);
530 let document = PrincipalDocument {
531 id: "https://profiles.example.com/org/acme".to_owned(),
532 kind: None,
533 name: None,
534 description: None,
535 avatar_url: None,
536 links: Vec::new(),
537 controllers: vec![controller.agent_id()],
538 delegations_url: Some("https://profiles.example.com/v1/delegations/query".to_owned()),
539 updated_at: None,
540 extra: BTreeMap::new(),
541 };
542 validate_principal_document(&document).unwrap();
543
544 let mut invalid = document;
545 invalid.controllers.clear();
546 assert!(validate_principal_document(&invalid).is_err());
547 }
548
549 #[test]
550 fn rejects_malformed_https_like_urls() {
551 let signer = AgentSigner::from_seed([35; 32]);
552 for principal_id in ["https://", "https://[::1", "http://example.com"] {
553 let payload = DelegationGrantPayload::new(
554 "del",
555 PrincipalDescriptor::new(principal_id),
556 signer.agent_id(),
557 vec!["scope".to_owned()],
558 );
559 assert!(validate_delegation_grant_payload(&payload, None).is_err());
560 }
561 }
562}