1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
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)]
55pub struct Controller {
56 pub id: AgentId,
57 pub source: String,
58 pub valid_from: i64,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub name: Option<String>,
61 #[serde(
62 default,
63 deserialize_with = "non_null_option",
64 skip_serializing_if = "Option::is_none"
65 )]
66 pub delegation: Option<DelegationAuthority>,
67 #[serde(
68 default,
69 deserialize_with = "non_null_option",
70 skip_serializing_if = "Option::is_none"
71 )]
72 pub retired_at: Option<i64>,
73 #[serde(
74 default,
75 deserialize_with = "non_null_option",
76 skip_serializing_if = "Option::is_none"
77 )]
78 pub invalid_from: Option<i64>,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
82#[serde(untagged)]
83pub enum DelegationAuthority {
84 Unrestricted(String),
86 Restricted(DelegationPolicy),
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
90#[serde(deny_unknown_fields)]
91pub struct DelegationPolicy {
92 pub scopes: Vec<String>,
93 pub audiences: Vec<String>,
94}
95
96#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
97pub struct DelegationAcceptance {
98 pub event_id: String,
99 pub accepted_at: i64,
100}
101
102fn non_null_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
103where
104 D: serde::Deserializer<'de>,
105 T: Deserialize<'de>,
106{
107 T::deserialize(deserializer).map(Some)
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
111pub struct PrincipalDocument {
112 pub id: String,
113 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
114 pub kind: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub name: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub description: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub avatar_url: Option<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub aliases: Vec<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub links: Vec<PrincipalLink>,
126 pub protocol: String,
127 pub controllers: Vec<Controller>,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub retired_controllers: Vec<Controller>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub delegation_query_url: Option<String>,
133 pub updated_at: i64,
134 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135 pub extra: BTreeMap<String, Value>,
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
139pub struct DelegationGrantPayload {
140 pub id: String,
141 pub principal: PrincipalDescriptor,
142 pub subject: AgentId,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub relationship: Option<String>,
145 pub scopes: Vec<String>,
146 pub audiences: Vec<String>,
147 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148 pub constraints: BTreeMap<String, Value>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub not_before: Option<i64>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub expires_at: Option<i64>,
153}
154
155impl DelegationGrantPayload {
156 pub fn new(
157 id: impl Into<String>,
158 principal: PrincipalDescriptor,
159 subject: AgentId,
160 scopes: Vec<String>,
161 audiences: Vec<String>,
162 ) -> Self {
163 Self {
164 id: id.into(),
165 principal,
166 subject,
167 relationship: None,
168 scopes,
169 audiences,
170 constraints: BTreeMap::new(),
171 not_before: None,
172 expires_at: None,
173 }
174 }
175}
176
177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
178pub struct DelegationRevokePayload {
179 pub id: String,
180 pub principal_id: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub reason: Option<String>,
183}
184
185#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
186#[serde(untagged)]
187pub enum DelegationPayload {
188 Grant(DelegationGrantPayload),
189 Revoke(DelegationRevokePayload),
190}
191
192#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
193pub struct DelegationCredential {
194 pub id: String,
195 pub protocol: String,
196 pub principal: PrincipalDescriptor,
197 pub controller: AgentId,
198 pub owner_controller: AgentId,
199 pub grant_event_id: String,
200 pub accepted_at: i64,
201 pub subject: AgentId,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub relationship: Option<String>,
204 pub scopes: Vec<String>,
205 pub audiences: Vec<String>,
206 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub constraints: BTreeMap<String, Value>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub not_before: Option<i64>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub expires_at: Option<i64>,
212 pub status: DelegationStatus,
213 pub updated_at: i64,
214 pub event_id: String,
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
218pub struct DelegationStatusDocument {
219 pub protocol: String,
220 pub grant_event_id: String,
221 pub accepted_at: i64,
222 pub id: String,
223 pub status: DelegationStatus,
224 pub checked_at: i64,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub expires_at: Option<i64>,
227 pub event_id: String,
228}
229
230#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
231pub struct DelegationServiceDiscovery {
232 pub protocol: String,
233 pub service: String,
234 pub endpoints: DelegationServiceEndpoints,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub features: Vec<String>,
237}
238
239#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
240pub struct DelegationServiceEndpoints {
241 pub delegations: String,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub query: Option<String>,
244}
245
246#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
247pub struct DelegationQueryRequest {
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub subject: Option<AgentId>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub principal_id: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub id: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub status: Option<DelegationStatus>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub limit: Option<u32>,
258}
259
260#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
261pub struct DelegationSummary {
262 pub id: String,
263 pub subject: AgentId,
264 pub principal: PrincipalDescriptor,
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub scopes: Vec<String>,
267 pub status: DelegationStatus,
268}
269
270#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
271pub struct DelegationQueryResponse {
272 pub result: Vec<DelegationSummary>,
273}
274
275#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
276pub struct DelegationEventsResponse {
277 pub result: Vec<Envelope<DelegationPayload>>,
278 pub acceptances: Vec<DelegationAcceptance>,
279}
280
281pub fn delegation_grant_event(
282 actor: AgentId,
283 created_at: i64,
284 nonce: u64,
285 payload: DelegationGrantPayload,
286) -> Event<DelegationGrantPayload> {
287 Event::new(
288 PROTOCOL,
289 DELEGATION_GRANT,
290 actor,
291 created_at,
292 nonce,
293 payload,
294 )
295}
296
297pub fn delegation_revoke_event(
298 actor: AgentId,
299 created_at: i64,
300 nonce: u64,
301 payload: DelegationRevokePayload,
302) -> Event<DelegationRevokePayload> {
303 Event::new(
304 PROTOCOL,
305 DELEGATION_REVOKE,
306 actor,
307 created_at,
308 nonce,
309 payload,
310 )
311}
312
313pub fn validate_controller(controller: &Controller, retired: bool) -> Result<()> {
314 controller.id.public_key_bytes()?;
315 if controller.source != "local" {
316 validate_origin(&controller.source)?;
317 }
318 timestamp(controller.valid_from, "valid_from")?;
319 if let Some(name) = &controller.name {
320 validate_non_empty(name, "name")?;
321 }
322 match &controller.delegation {
323 Some(DelegationAuthority::Unrestricted(value)) if value != "*" => {
324 return fail("invalid delegation policy")
325 }
326 Some(DelegationAuthority::Restricted(policy)) => {
327 strings(&policy.scopes, "scopes", false)?;
328 strings(&policy.audiences, "audiences", false)?;
329 for origin in &policy.audiences {
330 validate_origin(origin)?;
331 }
332 }
333 _ => {}
334 }
335 if retired {
336 let end = controller
337 .retired_at
338 .ok_or_else(|| SdkError::InvalidPayload("retired_at required".into()))?;
339 timestamp(end, "retired_at")?;
340 if end < controller.valid_from {
341 return fail("retired_at precedes valid_from");
342 }
343 if let Some(cutoff) = controller.invalid_from {
344 timestamp(cutoff, "invalid_from")?;
345 if cutoff < controller.valid_from || cutoff > end {
346 return fail("invalid compromise interval");
347 }
348 }
349 } else if controller.retired_at.is_some() || controller.invalid_from.is_some() {
350 return fail("current controller has retirement fields");
351 }
352 Ok(())
353}
354
355pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
356 validate_https_url(&document.id, "principal.id")?;
357 if document.protocol != PROTOCOL {
358 return fail("invalid principal protocol");
359 }
360 timestamp(document.updated_at, "updated_at")?;
361 let mut seen = BTreeSet::new();
362 for (records, retired) in [
363 (&document.controllers, false),
364 (&document.retired_controllers, true),
365 ] {
366 for record in records {
367 validate_controller(record, retired)?;
368 if !seen.insert(record.id.to_string()) {
369 return fail("duplicate controller key");
370 }
371 if record.valid_from > document.updated_at
372 || record.retired_at.is_some_and(|v| v > document.updated_at)
373 {
374 return fail("controller timestamp exceeds document update");
375 }
376 }
377 }
378 strings(&document.aliases, "aliases", true)?;
379 for alias in &document.aliases {
380 validate_https_url(alias, "alias")?;
381 }
382 if let Some(url) = &document.avatar_url {
383 validate_https_url(url, "avatar_url")?;
384 }
385 if let Some(url) = &document.delegation_query_url {
386 validate_https_url(url, "delegation_query_url")?;
387 }
388 Ok(())
389}
390
391pub fn validate_principal_resolution(
396 document: &PrincipalDocument,
397 resolved_url: &str,
398) -> Result<()> {
399 if document.id == resolved_url {
400 Ok(())
401 } else {
402 Err(SdkError::InvalidPayload(format!(
403 "principal document id {} was served at {resolved_url}",
404 document.id
405 )))
406 }
407}
408
409pub fn is_principal_alias(document: &PrincipalDocument, url: &str) -> bool {
413 document.aliases.iter().any(|alias| alias == url)
414}
415
416pub fn validate_delegation_grant_payload(
417 payload: &DelegationGrantPayload,
418 created_at: Option<i64>,
419) -> Result<()> {
420 validate_delegation_id(&payload.id)?;
421 validate_principal_descriptor(&payload.principal)?;
422 payload.subject.public_key_bytes()?;
423 strings(&payload.scopes, "scopes", false)?;
424 strings(&payload.audiences, "audiences", false)?;
425 for origin in &payload.audiences {
426 validate_origin(origin)?;
427 }
428 if let Some(time) = created_at {
429 timestamp(time, "created_at")?;
430 }
431 if let Some(time) = payload.not_before {
432 timestamp(time, "not_before")?;
433 }
434 if let Some(time) = payload.expires_at {
435 timestamp(time, "expires_at")?;
436 }
437 if let Some(expires_at) = payload.expires_at {
438 if matches!(payload.not_before, Some(not_before) if expires_at <= not_before) {
439 return Err(SdkError::InvalidPayload(
440 "expires_at must be greater than not_before".to_owned(),
441 ));
442 }
443 if matches!(created_at, Some(created_at) if expires_at <= created_at) {
444 return Err(SdkError::InvalidPayload(
445 "expires_at must be greater than created_at".to_owned(),
446 ));
447 }
448 }
449 Ok(())
450}
451
452pub fn validate_delegation_query_request(
458 request: &DelegationQueryRequest,
459 allow_enumeration: bool,
460) -> Result<()> {
461 if allow_enumeration {
462 if request.subject.is_none() && request.principal_id.is_none() {
463 return Err(SdkError::InvalidPayload(
464 "query must include at least one of subject or principal_id".to_owned(),
465 ));
466 }
467 } else if request.subject.is_none() || request.principal_id.is_none() {
468 return Err(SdkError::InvalidPayload(
469 "public query must include both subject and principal_id".to_owned(),
470 ));
471 }
472 if let Some(subject) = &request.subject {
473 subject.public_key_bytes()?;
474 }
475 if let Some(principal_id) = &request.principal_id {
476 validate_https_url(principal_id, "principal_id")?;
477 }
478 if let Some(id) = &request.id {
479 validate_delegation_id(id)?;
480 }
481 if matches!(request.limit, Some(0)) {
482 return Err(SdkError::InvalidPayload(
483 "limit must be a positive integer".to_owned(),
484 ));
485 }
486 Ok(())
487}
488
489pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
490 validate_delegation_id(&payload.id)?;
491 validate_https_url(&payload.principal_id, "principal_id")
492}
493
494pub fn validate_delegation_id(value: &str) -> Result<()> {
495 validate_non_empty(value, "delegation id")?;
496 if matches!(value, "." | "..") {
497 return fail("delegation id cannot be a dot segment");
498 }
499 Ok(())
500}
501
502pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
503 verify_envelope(envelope)?;
504 if envelope.event.protocol != PROTOCOL {
505 return Err(SdkError::InvalidEventProtocol {
506 expected: PROTOCOL.to_owned(),
507 actual: envelope.event.protocol.clone(),
508 });
509 }
510 match (&envelope.event.kind[..], &envelope.event.payload) {
511 (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
512 validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
513 }
514 (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
515 validate_delegation_revoke_payload(payload)
516 }
517 (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
518 "delegation event type does not match payload shape".to_owned(),
519 )),
520 _ => Err(SdkError::InvalidEventType {
521 expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
522 actual: envelope.event.kind.clone(),
523 }),
524 }
525}
526
527pub fn materialize_delegation_credential(
530 envelope: &Envelope<DelegationPayload>,
531 status: DelegationStatus,
532 accepted_at: i64,
533 previous: Option<&DelegationCredential>,
534) -> Result<DelegationCredential> {
535 validate_delegation_envelope(envelope)?;
536 timestamp(accepted_at, "accepted_at")?;
537 check_previous(&envelope.event, previous)?;
538 if previous.is_some_and(|p| accepted_at < p.accepted_at) {
539 return fail("acceptance order reversed");
540 }
541 match &envelope.event.payload {
542 DelegationPayload::Revoke(_) => {
543 let mut result = previous
544 .ok_or_else(|| {
545 SdkError::InvalidPayload("revocation requires previous credential".into())
546 })?
547 .clone();
548 result.controller = envelope.event.actor.clone();
549 result.status = DelegationStatus::Revoked;
550 result.event_id = envelope.hash.clone();
551 result.accepted_at = accepted_at;
552 result.updated_at = accepted_at;
553 Ok(result)
554 }
555 DelegationPayload::Grant(payload) => {
556 if payload.expires_at.is_some_and(|t| t <= accepted_at) {
557 return fail("grant expired at acceptance");
558 }
559 Ok(DelegationCredential {
560 id: payload.id.clone(),
561 protocol: PROTOCOL.into(),
562 principal: payload.principal.clone(),
563 controller: envelope.event.actor.clone(),
564 owner_controller: previous
565 .map(|p| p.owner_controller.clone())
566 .unwrap_or_else(|| envelope.event.actor.clone()),
567 grant_event_id: envelope.hash.clone(),
568 accepted_at,
569 subject: payload.subject.clone(),
570 relationship: payload.relationship.clone(),
571 scopes: payload.scopes.clone(),
572 audiences: payload.audiences.clone(),
573 constraints: payload.constraints.clone(),
574 not_before: payload.not_before,
575 expires_at: payload.expires_at,
576 status,
577 updated_at: accepted_at,
578 event_id: envelope.hash.clone(),
579 })
580 }
581 }
582}
583
584pub fn validate_delegation_event_authority(
587 event: &Event<DelegationPayload>,
588 document: &PrincipalDocument,
589 accepted_at: i64,
590 previous: Option<&DelegationCredential>,
591) -> Result<()> {
592 check_authority(event, document, accepted_at, previous, false)
593}
594
595pub fn validate_delegation_acceptance(
597 envelope: &Envelope<DelegationPayload>,
598 document: &PrincipalDocument,
599 resolved_url: &str,
600 accepted_at: i64,
601 previous: Option<&DelegationCredential>,
602) -> Result<()> {
603 validate_delegation_envelope(envelope)?;
604 validate_principal_resolution(document, resolved_url)?;
605 check_authority(&envelope.event, document, accepted_at, previous, false)
606}
607
608pub fn validate_historical_delegation(
611 envelope: &Envelope<DelegationPayload>,
612 acceptance: &DelegationAcceptance,
613 document: &PrincipalDocument,
614 resolved_url: &str,
615 previous: Option<&DelegationCredential>,
616) -> Result<()> {
617 validate_delegation_envelope(envelope)?;
618 if acceptance.event_id != envelope.hash {
619 return fail("acceptance hash mismatch");
620 }
621 validate_principal_resolution(document, resolved_url)?;
622 check_authority(
623 &envelope.event,
624 document,
625 acceptance.accepted_at,
626 previous,
627 true,
628 )
629}
630
631pub fn validate_controller_enumeration(
633 document: &PrincipalDocument,
634 actor: &AgentId,
635 now: i64,
636 owner: &AgentId,
637) -> Result<()> {
638 validate_principal_document(document)?;
639 timestamp(now, "now")?;
640 owner.public_key_bytes()?;
641 let c = document
642 .controllers
643 .iter()
644 .find(|c| &c.id == actor)
645 .ok_or_else(|| SdkError::InvalidPayload("controller cannot enumerate".into()))?;
646 if now < c.valid_from || c.delegation.is_none() {
647 return fail("controller cannot enumerate");
648 }
649 if !unrestricted(c) && owner != actor {
650 return fail("controller does not own credential");
651 }
652 Ok(())
653}
654
655pub fn validate_delegation_use(
658 credential: &DelegationCredential,
659 audience: &str,
660 now: i64,
661) -> Result<()> {
662 validate_origin(audience)?;
663 timestamp(now, "now")?;
664 let payload = DelegationGrantPayload {
665 id: credential.id.clone(),
666 principal: credential.principal.clone(),
667 subject: credential.subject.clone(),
668 relationship: credential.relationship.clone(),
669 scopes: credential.scopes.clone(),
670 audiences: credential.audiences.clone(),
671 constraints: credential.constraints.clone(),
672 not_before: credential.not_before,
673 expires_at: credential.expires_at,
674 };
675 validate_delegation_grant_payload(&payload, None)?;
676 if credential.protocol != PROTOCOL
677 || credential.status != DelegationStatus::Active
678 || !credential.audiences.iter().any(|a| a == audience)
679 || credential.not_before.is_some_and(|t| now < t)
680 || credential.expires_at.is_some_and(|t| now >= t)
681 {
682 return fail("delegation is not usable");
683 }
684 Ok(())
685}
686
687fn event_identity(event: &Event<DelegationPayload>) -> (&str, &str) {
688 match &event.payload {
689 DelegationPayload::Grant(p) => (&p.id, &p.principal.id),
690 DelegationPayload::Revoke(p) => (&p.id, &p.principal_id),
691 }
692}
693fn check_previous(
694 event: &Event<DelegationPayload>,
695 previous: Option<&DelegationCredential>,
696) -> Result<()> {
697 let (id, principal) = event_identity(event);
698 if let Some(previous) = previous {
699 previous.owner_controller.public_key_bytes()?;
700 timestamp(previous.accepted_at, "previous.accepted_at")?;
701 if previous.id != id || previous.principal.id != principal || previous.protocol != PROTOCOL
702 {
703 return fail("previous credential identity mismatch");
704 }
705 } else if event.kind == DELEGATION_REVOKE {
706 return fail("revocation requires previous credential");
707 }
708 Ok(())
709}
710fn unrestricted(c: &Controller) -> bool {
711 matches!(&c.delegation, Some(DelegationAuthority::Unrestricted(v)) if v == "*")
712}
713fn check_authority(
714 event: &Event<DelegationPayload>,
715 document: &PrincipalDocument,
716 accepted_at: i64,
717 previous: Option<&DelegationCredential>,
718 historical: bool,
719) -> Result<()> {
720 validate_principal_document(document)?;
721 timestamp(accepted_at, "accepted_at")?;
722 timestamp(event.created_at, "created_at")?;
723 event.actor.public_key_bytes()?;
724 if event.protocol != PROTOCOL {
725 return fail("invalid event protocol");
726 }
727 match (&event.kind[..], &event.payload) {
728 (DELEGATION_GRANT, DelegationPayload::Grant(p)) => {
729 validate_delegation_grant_payload(p, Some(event.created_at))?
730 }
731 (DELEGATION_REVOKE, DelegationPayload::Revoke(p)) => validate_delegation_revoke_payload(p)?,
732 _ => return fail("event type does not match payload"),
733 }
734 if event_identity(event).1 != document.id {
735 return fail("principal mismatch");
736 }
737 let c = document
738 .controllers
739 .iter()
740 .chain(document.retired_controllers.iter().filter(|_| historical))
741 .find(|c| c.id == event.actor)
742 .ok_or_else(|| SdkError::InvalidPayload("actor has no delegation authority".into()))?;
743 if c.delegation.is_none() {
744 return fail("actor has no delegation authority");
745 }
746 for time in [event.created_at, accepted_at] {
747 if time < c.valid_from
748 || c.retired_at.is_some_and(|t| time >= t)
749 || c.invalid_from.is_some_and(|t| time >= t)
750 {
751 return fail("outside controller authority interval");
752 }
753 }
754 check_previous(event, previous)?;
755 if let Some(previous) = previous {
756 if accepted_at < previous.accepted_at {
757 return fail("acceptance order reversed");
758 }
759 if !unrestricted(c) && previous.owner_controller != event.actor {
760 return fail("controller does not own credential");
761 }
762 }
763 if let DelegationPayload::Grant(payload) = &event.payload {
764 if payload.expires_at.is_some_and(|t| t <= accepted_at) {
765 return fail("grant expired at acceptance");
766 }
767 if let Some(DelegationAuthority::Restricted(policy)) = &c.delegation {
768 if payload.scopes.iter().any(|x| !policy.scopes.contains(x))
769 || payload
770 .audiences
771 .iter()
772 .any(|x| !policy.audiences.contains(x))
773 {
774 return fail("grant exceeds controller delegation policy");
775 }
776 }
777 }
778 Ok(())
779}
780fn fail<T>(message: &str) -> Result<T> {
781 Err(SdkError::InvalidPayload(message.into()))
782}
783fn timestamp(value: i64, field: &str) -> Result<()> {
784 if !(0..=9_007_199_254_740_991).contains(&value) {
785 return fail(&format!("{field} must be a non-negative safe integer"));
786 }
787 Ok(())
788}
789fn strings(values: &[String], field: &str, empty: bool) -> Result<()> {
790 if !empty && values.is_empty() {
791 return fail(&format!("{field} must be a non-empty array"));
792 }
793 let mut seen = BTreeSet::new();
794 for value in values {
795 validate_non_empty(value, field)?;
796 if value == "*" || !seen.insert(value) {
797 return fail(&format!("{field} has wildcard or duplicates"));
798 }
799 }
800 Ok(())
801}
802fn validate_origin(value: &str) -> Result<()> {
803 validate_https_url(value, "origin")?;
804 let url = Url::parse(value).map_err(|_| SdkError::InvalidPayload("invalid origin".into()))?;
805 if url.origin().ascii_serialization() != value {
806 return fail("origin must be a serialized HTTPS origin");
807 }
808 Ok(())
809}
810
811fn validate_principal_descriptor(principal: &PrincipalDescriptor) -> Result<()> {
812 validate_https_url(&principal.id, "principal.id")
813}
814
815fn validate_https_url(value: &str, field: &str) -> Result<()> {
816 let parsed = Url::parse(value)
817 .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
818 if parsed.scheme() == "https" && parsed.host_str().is_some() {
819 Ok(())
820 } else {
821 Err(SdkError::InvalidPayload(format!(
822 "{field} must be an HTTPS URL"
823 )))
824 }
825}
826
827fn validate_non_empty(value: &str, field: &str) -> Result<()> {
828 if value.trim().is_empty() {
829 Err(SdkError::InvalidPayload(format!(
830 "{field} must not be empty"
831 )))
832 } else {
833 Ok(())
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use crate::identity::AgentSigner;
841
842 #[test]
843 fn validates_and_materializes_grant() {
844 let controller = AgentSigner::from_seed([31; 32]);
845 let subject = AgentSigner::from_seed([32; 32]);
846 let mut payload = DelegationGrantPayload::new(
847 "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
848 PrincipalDescriptor {
849 id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
850 kind: Some("person".to_owned()),
851 name: Some("Yan".to_owned()),
852 },
853 subject.agent_id(),
854 vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
855 vec!["https://dmsg.net".to_owned()],
856 );
857 payload.relationship = Some("primary_delegate".to_owned());
858 payload.not_before = Some(1_779_753_600_000);
859 payload.expires_at = Some(1_790_000_000_000);
860 let envelope = controller
861 .sign_event(delegation_grant_event(
862 controller.agent_id(),
863 1_779_753_600_000,
864 1,
865 payload,
866 ))
867 .unwrap();
868 let envelope_value = serde_json::to_value(&envelope).unwrap();
869 let envelope_for_validation: Envelope<DelegationPayload> =
870 serde_json::from_value(envelope_value).unwrap();
871
872 validate_delegation_envelope(&envelope_for_validation).unwrap();
873 let credential = materialize_delegation_credential(
874 &envelope_for_validation,
875 DelegationStatus::Active,
876 1_779_753_600_000,
877 None,
878 )
879 .unwrap();
880
881 assert_eq!(credential.protocol, PROTOCOL);
882 assert_eq!(credential.controller, controller.agent_id());
883 assert_eq!(credential.subject, subject.agent_id());
884 assert_eq!(credential.event_id, envelope.hash);
885 }
886
887 #[test]
888 fn validates_revoke_and_rejects_invalid_grants() {
889 let controller = AgentSigner::from_seed([33; 32]);
890 let envelope = controller
891 .sign_event(delegation_revoke_event(
892 controller.agent_id(),
893 1_779_753_700_000,
894 2,
895 DelegationRevokePayload {
896 id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
897 principal_id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
898 reason: Some("rotated_primary_agent".to_owned()),
899 },
900 ))
901 .unwrap();
902 let envelope: Envelope<DelegationPayload> =
903 serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
904 validate_delegation_envelope(&envelope).unwrap();
905
906 let invalid = DelegationGrantPayload::new(
907 "del",
908 PrincipalDescriptor::new("http://example.com"),
909 controller.agent_id(),
910 Vec::new(),
911 vec!["https://dmsg.net".to_owned()],
912 );
913 assert!(validate_delegation_grant_payload(&invalid, None).is_err());
914 }
915
916 #[test]
917 fn grant_expiry_checked_against_not_before_and_created_at() {
918 let controller = AgentSigner::from_seed([36; 32]);
919 let base = DelegationGrantPayload::new(
920 "del_1",
921 PrincipalDescriptor::new("https://api.al.ink/d9c6a99cne5g00a6scn0"),
922 controller.agent_id(),
923 vec!["inbox.screen".to_owned()],
924 vec!["https://dmsg.net".to_owned()],
925 );
926
927 let mut same_as_not_before = base.clone();
928 same_as_not_before.not_before = Some(2000);
929 same_as_not_before.expires_at = Some(2000);
930 assert!(validate_delegation_grant_payload(&same_as_not_before, Some(1000)).is_err());
931
932 let mut before_created_at = base.clone();
933 before_created_at.not_before = Some(500);
934 before_created_at.expires_at = Some(800);
935 assert!(validate_delegation_grant_payload(&before_created_at, Some(1000)).is_err());
936
937 let mut valid = base;
938 valid.not_before = Some(500);
939 valid.expires_at = Some(1500);
940 validate_delegation_grant_payload(&valid, Some(1000)).unwrap();
941 }
942
943 #[test]
944 fn public_delegation_queries_carry_both_subject_and_principal() {
945 let subject = AgentSigner::from_seed([37; 32]).agent_id();
946 let principal_id = "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned();
947 let public = DelegationQueryRequest {
948 subject: Some(subject.clone()),
949 principal_id: Some(principal_id.clone()),
950 limit: Some(20),
951 ..DelegationQueryRequest::default()
952 };
953 validate_delegation_query_request(&public, false).unwrap();
954
955 for one_sided in [
957 DelegationQueryRequest {
958 subject: Some(subject.clone()),
959 ..DelegationQueryRequest::default()
960 },
961 DelegationQueryRequest {
962 principal_id: Some(principal_id),
963 ..DelegationQueryRequest::default()
964 },
965 ] {
966 assert!(validate_delegation_query_request(&one_sided, false).is_err());
967 validate_delegation_query_request(&one_sided, true).unwrap();
968 }
969
970 assert!(
971 validate_delegation_query_request(&DelegationQueryRequest::default(), true).is_err()
972 );
973 assert!(validate_delegation_query_request(
974 &DelegationQueryRequest {
975 limit: Some(0),
976 ..public
977 },
978 false
979 )
980 .is_err());
981 }
982
983 #[test]
984 fn principal_documents_bind_controllers_only_at_their_own_id() {
985 let controller = AgentSigner::from_seed([38; 32]);
986 let document = PrincipalDocument {
987 id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
988 kind: None,
989 name: None,
990 description: None,
991 avatar_url: None,
992 aliases: vec!["https://al.ink/yan".to_owned()],
993 links: Vec::new(),
994 protocol: PROTOCOL.into(),
995 retired_controllers: vec![],
996 controllers: vec![Controller {
997 id: controller.agent_id(),
998 source: "local".into(),
999 valid_from: 0,
1000 name: None,
1001 delegation: None,
1002 retired_at: None,
1003 invalid_from: None,
1004 }],
1005 delegation_query_url: None,
1006 updated_at: 1000,
1007 extra: BTreeMap::new(),
1008 };
1009
1010 validate_principal_resolution(&document, "https://api.al.ink/d9c6a99cne5g00a6scn0")
1011 .unwrap();
1012 assert!(
1014 validate_principal_resolution(&document, "https://impostor.example.com/yan").is_err()
1015 );
1016
1017 assert!(is_principal_alias(&document, "https://al.ink/yan"));
1018 assert!(!is_principal_alias(
1019 &document,
1020 "https://impostor.example.com/yan"
1021 ));
1022 }
1023
1024 #[test]
1025 fn validates_principal_document() {
1026 let controller = AgentSigner::from_seed([34; 32]);
1027 let document = PrincipalDocument {
1028 id: "https://profiles.example.com/org/acme".to_owned(),
1029 kind: None,
1030 name: None,
1031 description: None,
1032 avatar_url: None,
1033 aliases: vec!["https://profiles.example.com/acme".to_owned()],
1034 links: Vec::new(),
1035 protocol: PROTOCOL.into(),
1036 retired_controllers: vec![],
1037 controllers: vec![Controller {
1038 id: controller.agent_id(),
1039 source: "local".into(),
1040 valid_from: 0,
1041 name: None,
1042 delegation: None,
1043 retired_at: None,
1044 invalid_from: None,
1045 }],
1046 delegation_query_url: Some(
1047 "https://profiles.example.com/v1/delegations/query".to_owned(),
1048 ),
1049 updated_at: 1000,
1050 extra: BTreeMap::new(),
1051 };
1052 validate_principal_document(&document).unwrap();
1053
1054 let mut invalid = document;
1055 invalid.protocol = "old-draft".into();
1056 assert!(validate_principal_document(&invalid).is_err());
1057 }
1058
1059 #[test]
1060 fn rejects_malformed_https_like_urls() {
1061 let signer = AgentSigner::from_seed([35; 32]);
1062 for principal_id in ["https://", "https://[::1", "http://example.com"] {
1063 let payload = DelegationGrantPayload::new(
1064 "del",
1065 PrincipalDescriptor::new(principal_id),
1066 signer.agent_id(),
1067 vec!["scope".to_owned()],
1068 vec!["https://dmsg.net".to_owned()],
1069 );
1070 assert!(validate_delegation_grant_payload(&payload, None).is_err());
1071 }
1072 }
1073}