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        let not_before = payload.not_before.or(created_at);
271        if not_before
272            .map(|not_before| expires_at <= not_before)
273            .unwrap_or(false)
274        {
275            return Err(SdkError::InvalidPayload(
276                "expires_at must be greater than not_before or created_at".to_owned(),
277            ));
278        }
279    }
280    Ok(())
281}
282
283pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
284    validate_non_empty(&payload.id, "payload.id")?;
285    validate_https_url(&payload.principal_id, "principal_id")
286}
287
288pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
289    verify_envelope(envelope)?;
290    if envelope.event.protocol != PROTOCOL {
291        return Err(SdkError::InvalidEventProtocol {
292            expected: PROTOCOL.to_owned(),
293            actual: envelope.event.protocol.clone(),
294        });
295    }
296    match (&envelope.event.kind[..], &envelope.event.payload) {
297        (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
298            validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
299        }
300        (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
301            validate_delegation_revoke_payload(payload)
302        }
303        (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
304            "delegation event type does not match payload shape".to_owned(),
305        )),
306        _ => Err(SdkError::InvalidEventType {
307            expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
308            actual: envelope.event.kind.clone(),
309        }),
310    }
311}
312
313pub fn materialize_delegation_credential(
314    envelope: &Envelope<DelegationGrantPayload>,
315    status: DelegationStatus,
316    status_url: Option<String>,
317    updated_at: Option<i64>,
318) -> Result<DelegationCredential> {
319    verify_envelope(envelope)?;
320    if envelope.event.protocol != PROTOCOL {
321        return Err(SdkError::InvalidEventProtocol {
322            expected: PROTOCOL.to_owned(),
323            actual: envelope.event.protocol.clone(),
324        });
325    }
326    if envelope.event.kind != DELEGATION_GRANT {
327        return Err(SdkError::InvalidEventType {
328            expected: DELEGATION_GRANT.to_owned(),
329            actual: envelope.event.kind.clone(),
330        });
331    }
332    validate_delegation_grant_payload(&envelope.event.payload, Some(envelope.event.created_at))?;
333    let payload = &envelope.event.payload;
334    Ok(DelegationCredential {
335        id: payload.id.clone(),
336        protocol: PROTOCOL.to_owned(),
337        principal: payload.principal.clone(),
338        controller: envelope.event.actor.clone(),
339        subject: payload.subject.clone(),
340        relationship: payload.relationship.clone(),
341        scopes: payload.scopes.clone(),
342        constraints: payload.constraints.clone(),
343        not_before: payload.not_before,
344        expires_at: payload.expires_at,
345        status,
346        status_url,
347        updated_at: updated_at.unwrap_or(envelope.event.created_at),
348        event_id: envelope.hash.clone(),
349    })
350}
351
352fn validate_principal_descriptor(principal: &PrincipalDescriptor) -> Result<()> {
353    validate_https_url(&principal.id, "principal.id")
354}
355
356fn validate_https_url(value: &str, field: &str) -> Result<()> {
357    let parsed = Url::parse(value)
358        .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
359    if parsed.scheme() == "https" && parsed.host_str().is_some() {
360        Ok(())
361    } else {
362        Err(SdkError::InvalidPayload(format!(
363            "{field} must be an HTTPS URL"
364        )))
365    }
366}
367
368fn validate_non_empty(value: &str, field: &str) -> Result<()> {
369    if value.trim().is_empty() {
370        Err(SdkError::InvalidPayload(format!(
371            "{field} must not be empty"
372        )))
373    } else {
374        Ok(())
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::identity::AgentSigner;
382
383    #[test]
384    fn validates_and_materializes_grant() {
385        let controller = AgentSigner::from_seed([31; 32]);
386        let subject = AgentSigner::from_seed([32; 32]);
387        let mut payload = DelegationGrantPayload::new(
388            "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
389            PrincipalDescriptor {
390                id: "https://al.ink/yan".to_owned(),
391                kind: Some("person".to_owned()),
392                name: Some("Yan".to_owned()),
393            },
394            subject.agent_id(),
395            vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
396        );
397        payload.relationship = Some("primary_delegate".to_owned());
398        payload.not_before = Some(1_779_753_600_000);
399        payload.expires_at = Some(1_790_000_000_000);
400        let envelope = controller
401            .sign_event(delegation_grant_event(
402                controller.agent_id(),
403                1_779_753_600_000,
404                1,
405                payload,
406            ))
407            .unwrap();
408        let envelope_value = serde_json::to_value(&envelope).unwrap();
409        let envelope_for_validation: Envelope<DelegationPayload> =
410            serde_json::from_value(envelope_value).unwrap();
411
412        validate_delegation_envelope(&envelope_for_validation).unwrap();
413        let credential = materialize_delegation_credential(
414            &envelope,
415            DelegationStatus::Active,
416            Some("https://al.ink/v1/delegations/del/status".to_owned()),
417            None,
418        )
419        .unwrap();
420
421        assert_eq!(credential.protocol, PROTOCOL);
422        assert_eq!(credential.controller, controller.agent_id());
423        assert_eq!(credential.subject, subject.agent_id());
424        assert_eq!(credential.event_id, envelope.hash);
425    }
426
427    #[test]
428    fn validates_revoke_and_rejects_invalid_grants() {
429        let controller = AgentSigner::from_seed([33; 32]);
430        let envelope = controller
431            .sign_event(delegation_revoke_event(
432                controller.agent_id(),
433                1_779_753_700_000,
434                2,
435                DelegationRevokePayload {
436                    id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
437                    principal_id: "https://al.ink/yan".to_owned(),
438                    reason: Some("rotated_primary_agent".to_owned()),
439                },
440            ))
441            .unwrap();
442        let envelope: Envelope<DelegationPayload> =
443            serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
444        validate_delegation_envelope(&envelope).unwrap();
445
446        let invalid = DelegationGrantPayload::new(
447            "del",
448            PrincipalDescriptor::new("http://example.com"),
449            controller.agent_id(),
450            Vec::new(),
451        );
452        assert!(validate_delegation_grant_payload(&invalid, None).is_err());
453    }
454
455    #[test]
456    fn validates_principal_document() {
457        let controller = AgentSigner::from_seed([34; 32]);
458        let document = PrincipalDocument {
459            id: "https://profiles.example.com/org/acme".to_owned(),
460            kind: None,
461            name: None,
462            description: None,
463            avatar_url: None,
464            links: Vec::new(),
465            controllers: vec![controller.agent_id()],
466            delegations_url: Some("https://profiles.example.com/v1/delegations/query".to_owned()),
467            updated_at: None,
468            extra: BTreeMap::new(),
469        };
470        validate_principal_document(&document).unwrap();
471
472        let mut invalid = document;
473        invalid.controllers.clear();
474        assert!(validate_principal_document(&invalid).is_err());
475    }
476
477    #[test]
478    fn rejects_malformed_https_like_urls() {
479        let signer = AgentSigner::from_seed([35; 32]);
480        for principal_id in ["https://", "https://[::1", "http://example.com"] {
481            let payload = DelegationGrantPayload::new(
482                "del",
483                PrincipalDescriptor::new(principal_id),
484                signer.agent_id(),
485                vec!["scope".to_owned()],
486            );
487            assert!(validate_delegation_grant_payload(&payload, None).is_err());
488        }
489    }
490}