Skip to main content

agent_protocols/
delegation.rs

1//! Agent Delegation Protocol 1.0 types and validation helpers.
2//!
3//! Delegation events are ordinary Agent Identity envelopes whose actor is a
4//! controller key for an HTTPS principal document.
5
6use 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    /// Other HTTPS URLs that lead to this principal. Aliases are not identities.
65    #[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    /// Delegation query endpoint for this principal; answers existence checks.
71    #[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
262/// Checks the authoritative-read rule of Agent Delegation Section 5.3: a
263/// principal document binds controller keys only when it is read at its own
264/// `id`. A document served anywhere else is a copy; its `controllers` must be
265/// discarded and `document.id` resolved instead.
266pub 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
280/// Reports whether `url` is an alias the principal itself acknowledges. Any
281/// origin can redirect to any principal, so an alias must not be shown as a
282/// name for the principal unless it is listed here.
283pub 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
314/// A public delegation query is an existence check and must include both
315/// `subject` and `principal_id`. Omitting either side makes it an enumeration
316/// query, which services must authorize before answering; pass
317/// `allow_enumeration` when building such an authorized request. `limit`
318/// defaults to 20; services SHOULD cap it at 100.
319pub 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        // Enumerating one side is not a public query.
552        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        // A copy served away from its identifier carries no authority.
599        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}