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")]
66 pub aliases: Vec<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub links: Vec<PrincipalLink>,
69 pub controllers: Vec<AgentId>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub delegation_query_url: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub updated_at: Option<i64>,
75 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
76 pub extra: BTreeMap<String, Value>,
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80pub struct DelegationGrantPayload {
81 pub id: String,
82 pub principal: PrincipalDescriptor,
83 pub subject: AgentId,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub relationship: Option<String>,
86 pub scopes: Vec<String>,
87 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88 pub constraints: BTreeMap<String, Value>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub not_before: Option<i64>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub expires_at: Option<i64>,
93}
94
95impl DelegationGrantPayload {
96 pub fn new(
97 id: impl Into<String>,
98 principal: PrincipalDescriptor,
99 subject: AgentId,
100 scopes: Vec<String>,
101 ) -> Self {
102 Self {
103 id: id.into(),
104 principal,
105 subject,
106 relationship: None,
107 scopes,
108 constraints: BTreeMap::new(),
109 not_before: None,
110 expires_at: None,
111 }
112 }
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116pub struct DelegationRevokePayload {
117 pub id: String,
118 pub principal_id: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub reason: Option<String>,
121}
122
123#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
124#[serde(untagged)]
125pub enum DelegationPayload {
126 Grant(DelegationGrantPayload),
127 Revoke(DelegationRevokePayload),
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
131pub struct DelegationCredential {
132 pub id: String,
133 pub protocol: String,
134 pub principal: PrincipalDescriptor,
135 pub controller: AgentId,
136 pub subject: AgentId,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub relationship: Option<String>,
139 pub scopes: Vec<String>,
140 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
141 pub constraints: BTreeMap<String, Value>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub not_before: Option<i64>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub expires_at: Option<i64>,
146 pub status: DelegationStatus,
147 pub updated_at: i64,
148 pub event_id: String,
149}
150
151#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
152pub struct DelegationStatusDocument {
153 pub id: String,
154 pub status: DelegationStatus,
155 pub checked_at: i64,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub expires_at: Option<i64>,
158 pub event_id: String,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
162pub struct DelegationServiceDiscovery {
163 pub protocol: String,
164 pub service: String,
165 pub endpoints: DelegationServiceEndpoints,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub features: Vec<String>,
168}
169
170#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
171pub struct DelegationServiceEndpoints {
172 pub delegations: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub query: Option<String>,
175}
176
177#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
178pub struct DelegationQueryRequest {
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub subject: Option<AgentId>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub principal_id: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub id: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub status: Option<DelegationStatus>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub limit: Option<u32>,
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
192pub struct DelegationSummary {
193 pub id: String,
194 pub subject: AgentId,
195 pub principal: PrincipalDescriptor,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub scopes: Vec<String>,
198 pub status: DelegationStatus,
199}
200
201#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
202pub struct DelegationQueryResponse {
203 pub result: Vec<DelegationSummary>,
204}
205
206#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
207pub struct DelegationEventsResponse {
208 pub result: Vec<Envelope<DelegationPayload>>,
209}
210
211pub fn delegation_grant_event(
212 actor: AgentId,
213 created_at: i64,
214 nonce: u64,
215 payload: DelegationGrantPayload,
216) -> Event<DelegationGrantPayload> {
217 Event::new(
218 PROTOCOL,
219 DELEGATION_GRANT,
220 actor,
221 created_at,
222 nonce,
223 payload,
224 )
225}
226
227pub fn delegation_revoke_event(
228 actor: AgentId,
229 created_at: i64,
230 nonce: u64,
231 payload: DelegationRevokePayload,
232) -> Event<DelegationRevokePayload> {
233 Event::new(
234 PROTOCOL,
235 DELEGATION_REVOKE,
236 actor,
237 created_at,
238 nonce,
239 payload,
240 )
241}
242
243pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
244 validate_https_url(&document.id, "principal.id")?;
245 if document.controllers.is_empty() {
246 return Err(SdkError::InvalidPayload(
247 "principal document controllers must be non-empty".to_owned(),
248 ));
249 }
250 for controller in &document.controllers {
251 controller.public_key_bytes()?;
252 }
253 for alias in &document.aliases {
254 validate_https_url(alias, "alias")?;
255 }
256 if let Some(url) = &document.delegation_query_url {
257 validate_https_url(url, "delegation_query_url")?;
258 }
259 Ok(())
260}
261
262pub fn validate_principal_resolution(
267 document: &PrincipalDocument,
268 resolved_url: &str,
269) -> Result<()> {
270 if document.id == resolved_url {
271 Ok(())
272 } else {
273 Err(SdkError::InvalidPayload(format!(
274 "principal document id {} was served at {resolved_url}",
275 document.id
276 )))
277 }
278}
279
280pub fn is_principal_alias(document: &PrincipalDocument, url: &str) -> bool {
284 document.aliases.iter().any(|alias| alias == url)
285}
286
287pub fn validate_delegation_grant_payload(
288 payload: &DelegationGrantPayload,
289 created_at: Option<i64>,
290) -> Result<()> {
291 validate_non_empty(&payload.id, "payload.id")?;
292 validate_principal_descriptor(&payload.principal)?;
293 payload.subject.public_key_bytes()?;
294 if payload.scopes.is_empty() || payload.scopes.iter().any(|scope| scope.trim().is_empty()) {
295 return Err(SdkError::InvalidPayload(
296 "delegation scopes must be non-empty".to_owned(),
297 ));
298 }
299 if let Some(expires_at) = payload.expires_at {
300 if matches!(payload.not_before, Some(not_before) if expires_at <= not_before) {
301 return Err(SdkError::InvalidPayload(
302 "expires_at must be greater than not_before".to_owned(),
303 ));
304 }
305 if matches!(created_at, Some(created_at) if expires_at <= created_at) {
306 return Err(SdkError::InvalidPayload(
307 "expires_at must be greater than created_at".to_owned(),
308 ));
309 }
310 }
311 Ok(())
312}
313
314pub fn validate_delegation_query_request(
320 request: &DelegationQueryRequest,
321 allow_enumeration: bool,
322) -> Result<()> {
323 if allow_enumeration {
324 if request.subject.is_none() && request.principal_id.is_none() {
325 return Err(SdkError::InvalidPayload(
326 "query must include at least one of subject or principal_id".to_owned(),
327 ));
328 }
329 } else if request.subject.is_none() || request.principal_id.is_none() {
330 return Err(SdkError::InvalidPayload(
331 "public query must include both subject and principal_id".to_owned(),
332 ));
333 }
334 if let Some(subject) = &request.subject {
335 subject.public_key_bytes()?;
336 }
337 if let Some(principal_id) = &request.principal_id {
338 validate_https_url(principal_id, "principal_id")?;
339 }
340 if matches!(request.limit, Some(0)) {
341 return Err(SdkError::InvalidPayload(
342 "limit must be a positive integer".to_owned(),
343 ));
344 }
345 Ok(())
346}
347
348pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
349 validate_non_empty(&payload.id, "payload.id")?;
350 validate_https_url(&payload.principal_id, "principal_id")
351}
352
353pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
354 verify_envelope(envelope)?;
355 if envelope.event.protocol != PROTOCOL {
356 return Err(SdkError::InvalidEventProtocol {
357 expected: PROTOCOL.to_owned(),
358 actual: envelope.event.protocol.clone(),
359 });
360 }
361 match (&envelope.event.kind[..], &envelope.event.payload) {
362 (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
363 validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
364 }
365 (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
366 validate_delegation_revoke_payload(payload)
367 }
368 (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
369 "delegation event type does not match payload shape".to_owned(),
370 )),
371 _ => Err(SdkError::InvalidEventType {
372 expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
373 actual: envelope.event.kind.clone(),
374 }),
375 }
376}
377
378pub fn materialize_delegation_credential(
379 envelope: &Envelope<DelegationGrantPayload>,
380 status: DelegationStatus,
381 updated_at: Option<i64>,
382) -> Result<DelegationCredential> {
383 verify_envelope(envelope)?;
384 if envelope.event.protocol != PROTOCOL {
385 return Err(SdkError::InvalidEventProtocol {
386 expected: PROTOCOL.to_owned(),
387 actual: envelope.event.protocol.clone(),
388 });
389 }
390 if envelope.event.kind != DELEGATION_GRANT {
391 return Err(SdkError::InvalidEventType {
392 expected: DELEGATION_GRANT.to_owned(),
393 actual: envelope.event.kind.clone(),
394 });
395 }
396 validate_delegation_grant_payload(&envelope.event.payload, Some(envelope.event.created_at))?;
397 let payload = &envelope.event.payload;
398 Ok(DelegationCredential {
399 id: payload.id.clone(),
400 protocol: PROTOCOL.to_owned(),
401 principal: payload.principal.clone(),
402 controller: envelope.event.actor.clone(),
403 subject: payload.subject.clone(),
404 relationship: payload.relationship.clone(),
405 scopes: payload.scopes.clone(),
406 constraints: payload.constraints.clone(),
407 not_before: payload.not_before,
408 expires_at: payload.expires_at,
409 status,
410 updated_at: updated_at.unwrap_or(envelope.event.created_at),
411 event_id: envelope.hash.clone(),
412 })
413}
414
415fn validate_principal_descriptor(principal: &PrincipalDescriptor) -> Result<()> {
416 validate_https_url(&principal.id, "principal.id")
417}
418
419fn validate_https_url(value: &str, field: &str) -> Result<()> {
420 let parsed = Url::parse(value)
421 .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
422 if parsed.scheme() == "https" && parsed.host_str().is_some() {
423 Ok(())
424 } else {
425 Err(SdkError::InvalidPayload(format!(
426 "{field} must be an HTTPS URL"
427 )))
428 }
429}
430
431fn validate_non_empty(value: &str, field: &str) -> Result<()> {
432 if value.trim().is_empty() {
433 Err(SdkError::InvalidPayload(format!(
434 "{field} must not be empty"
435 )))
436 } else {
437 Ok(())
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::identity::AgentSigner;
445
446 #[test]
447 fn validates_and_materializes_grant() {
448 let controller = AgentSigner::from_seed([31; 32]);
449 let subject = AgentSigner::from_seed([32; 32]);
450 let mut payload = DelegationGrantPayload::new(
451 "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
452 PrincipalDescriptor {
453 id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
454 kind: Some("person".to_owned()),
455 name: Some("Yan".to_owned()),
456 },
457 subject.agent_id(),
458 vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
459 );
460 payload.relationship = Some("primary_delegate".to_owned());
461 payload.not_before = Some(1_779_753_600_000);
462 payload.expires_at = Some(1_790_000_000_000);
463 let envelope = controller
464 .sign_event(delegation_grant_event(
465 controller.agent_id(),
466 1_779_753_600_000,
467 1,
468 payload,
469 ))
470 .unwrap();
471 let envelope_value = serde_json::to_value(&envelope).unwrap();
472 let envelope_for_validation: Envelope<DelegationPayload> =
473 serde_json::from_value(envelope_value).unwrap();
474
475 validate_delegation_envelope(&envelope_for_validation).unwrap();
476 let credential =
477 materialize_delegation_credential(&envelope, DelegationStatus::Active, None).unwrap();
478
479 assert_eq!(credential.protocol, PROTOCOL);
480 assert_eq!(credential.controller, controller.agent_id());
481 assert_eq!(credential.subject, subject.agent_id());
482 assert_eq!(credential.event_id, envelope.hash);
483 }
484
485 #[test]
486 fn validates_revoke_and_rejects_invalid_grants() {
487 let controller = AgentSigner::from_seed([33; 32]);
488 let envelope = controller
489 .sign_event(delegation_revoke_event(
490 controller.agent_id(),
491 1_779_753_700_000,
492 2,
493 DelegationRevokePayload {
494 id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
495 principal_id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
496 reason: Some("rotated_primary_agent".to_owned()),
497 },
498 ))
499 .unwrap();
500 let envelope: Envelope<DelegationPayload> =
501 serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
502 validate_delegation_envelope(&envelope).unwrap();
503
504 let invalid = DelegationGrantPayload::new(
505 "del",
506 PrincipalDescriptor::new("http://example.com"),
507 controller.agent_id(),
508 Vec::new(),
509 );
510 assert!(validate_delegation_grant_payload(&invalid, None).is_err());
511 }
512
513 #[test]
514 fn grant_expiry_checked_against_not_before_and_created_at() {
515 let controller = AgentSigner::from_seed([36; 32]);
516 let base = DelegationGrantPayload::new(
517 "del_1",
518 PrincipalDescriptor::new("https://api.al.ink/d9c6a99cne5g00a6scn0"),
519 controller.agent_id(),
520 vec!["inbox.screen".to_owned()],
521 );
522
523 let mut same_as_not_before = base.clone();
524 same_as_not_before.not_before = Some(2000);
525 same_as_not_before.expires_at = Some(2000);
526 assert!(validate_delegation_grant_payload(&same_as_not_before, Some(1000)).is_err());
527
528 let mut before_created_at = base.clone();
529 before_created_at.not_before = Some(500);
530 before_created_at.expires_at = Some(800);
531 assert!(validate_delegation_grant_payload(&before_created_at, Some(1000)).is_err());
532
533 let mut valid = base;
534 valid.not_before = Some(500);
535 valid.expires_at = Some(1500);
536 validate_delegation_grant_payload(&valid, Some(1000)).unwrap();
537 }
538
539 #[test]
540 fn public_delegation_queries_carry_both_subject_and_principal() {
541 let subject = AgentSigner::from_seed([37; 32]).agent_id();
542 let principal_id = "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned();
543 let public = DelegationQueryRequest {
544 subject: Some(subject.clone()),
545 principal_id: Some(principal_id.clone()),
546 limit: Some(20),
547 ..DelegationQueryRequest::default()
548 };
549 validate_delegation_query_request(&public, false).unwrap();
550
551 for one_sided in [
553 DelegationQueryRequest {
554 subject: Some(subject.clone()),
555 ..DelegationQueryRequest::default()
556 },
557 DelegationQueryRequest {
558 principal_id: Some(principal_id),
559 ..DelegationQueryRequest::default()
560 },
561 ] {
562 assert!(validate_delegation_query_request(&one_sided, false).is_err());
563 validate_delegation_query_request(&one_sided, true).unwrap();
564 }
565
566 assert!(
567 validate_delegation_query_request(&DelegationQueryRequest::default(), true).is_err()
568 );
569 assert!(validate_delegation_query_request(
570 &DelegationQueryRequest {
571 limit: Some(0),
572 ..public
573 },
574 false
575 )
576 .is_err());
577 }
578
579 #[test]
580 fn principal_documents_bind_controllers_only_at_their_own_id() {
581 let controller = AgentSigner::from_seed([38; 32]);
582 let document = PrincipalDocument {
583 id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
584 kind: None,
585 name: None,
586 description: None,
587 avatar_url: None,
588 aliases: vec!["https://al.ink/yan".to_owned()],
589 links: Vec::new(),
590 controllers: vec![controller.agent_id()],
591 delegation_query_url: None,
592 updated_at: None,
593 extra: BTreeMap::new(),
594 };
595
596 validate_principal_resolution(&document, "https://api.al.ink/d9c6a99cne5g00a6scn0")
597 .unwrap();
598 assert!(
600 validate_principal_resolution(&document, "https://impostor.example.com/yan").is_err()
601 );
602
603 assert!(is_principal_alias(&document, "https://al.ink/yan"));
604 assert!(!is_principal_alias(
605 &document,
606 "https://impostor.example.com/yan"
607 ));
608 }
609
610 #[test]
611 fn validates_principal_document() {
612 let controller = AgentSigner::from_seed([34; 32]);
613 let document = PrincipalDocument {
614 id: "https://profiles.example.com/org/acme".to_owned(),
615 kind: None,
616 name: None,
617 description: None,
618 avatar_url: None,
619 aliases: vec!["https://profiles.example.com/acme".to_owned()],
620 links: Vec::new(),
621 controllers: vec![controller.agent_id()],
622 delegation_query_url: Some(
623 "https://profiles.example.com/v1/delegations/query".to_owned(),
624 ),
625 updated_at: None,
626 extra: BTreeMap::new(),
627 };
628 validate_principal_document(&document).unwrap();
629
630 let mut invalid = document;
631 invalid.controllers.clear();
632 assert!(validate_principal_document(&invalid).is_err());
633 }
634
635 #[test]
636 fn rejects_malformed_https_like_urls() {
637 let signer = AgentSigner::from_seed([35; 32]);
638 for principal_id in ["https://", "https://[::1", "http://example.com"] {
639 let payload = DelegationGrantPayload::new(
640 "del",
641 PrincipalDescriptor::new(principal_id),
642 signer.agent_id(),
643 vec!["scope".to_owned()],
644 );
645 assert!(validate_delegation_grant_payload(&payload, None).is_err());
646 }
647 }
648}