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    #[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
284/// A delegation query MUST include at least one of `subject` or
285/// `principal_id`. `limit` defaults to 20; services SHOULD cap it at 100.
286pub 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}