Skip to main content

vta_sdk/protocols/acl_management/
create.rs

1//! `acl/grant/0.1` — add a subject to the ACL.
2
3use serde::{Deserialize, Serialize};
4
5use super::entry::AclEntry;
6
7/// `acl/grant/0.1` request.
8///
9/// The entry is **nested** rather than flattened because canonical `acl/*`
10/// carries one `AclEntry` shape across every task — grant, show, list, revoke
11/// — so a consumer comparing entries between them cannot end up looking at two
12/// different spellings of the same thing.
13///
14/// Note what grant deliberately cannot do. **Changing an existing subject's
15/// role** belongs to `acl/change-role`, which takes the current role as a
16/// compare-and-swap; **narrowing scopes** belongs to `acl/revoke`. Both are
17/// refused here rather than silently applied, so that a reduction in authority
18/// always passes through the task that is named and audited as one.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22pub struct CreateAclBody {
23    /// The entry the caller wants the maintainer to hold.
24    pub entry: AclEntry,
25    /// Optional human-readable rationale, recorded with the grant.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub reason: Option<String>,
28}
29
30/// `acl/grant/0.1` response — the realized entry the maintainer now holds.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34pub struct CreateAclResponseBody {
35    pub entry: AclEntry,
36}
37
38/// The maintainer's internal view of a stored entry.
39///
40/// **Not a wire type any more.** It is what `operations::acl` returns, and the
41/// handlers convert it to an [`AclEntry`] on the way out
42/// ([`AclEntry::from_result`]). Kept in its stored form — epoch seconds, flat
43/// approve members — so the storage layer is unaffected by the canonical fold.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
46pub struct CreateAclResultBody {
47    pub did: String,
48    pub role: String,
49    pub label: Option<String>,
50    pub allowed_contexts: Vec<String>,
51    pub created_at: u64,
52    pub created_by: String,
53    /// Unix-epoch seconds at which the entry auto-expires, if set.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub expires_at: Option<u64>,
56    /// The delegated step-up approver the maintainer now holds for this
57    /// subject, if any (echoes the stored `step_up_approver`).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub step_up_approver: Option<String>,
60    /// The per-entry step-up override the maintainer now holds for this subject,
61    /// if any (echoes the stored `step_up_require`).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub step_up_require: Option<String>,
64    /// Approve-authority the entry holds — `true` means it may confer *any*
65    /// context via approval (while acting nowhere). Echoes the stored
66    /// `approve_scope`; takes precedence over `approve_contexts`.
67    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
68    pub approve_all_contexts: bool,
69    /// Approve-authority scoped to these contexts (echoes the stored
70    /// `approve_scope`). Empty = confers nothing, unless `approve_all_contexts`.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub approve_contexts: Vec<String>,
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    /// The regression the pre-fold body existed to prevent, restated against
80    /// the canonical shape: a scoped, expiring grant must not lose its scope.
81    ///
82    /// A dropped `scopes` on an admin entry is a **permanent super-admin**,
83    /// because an empty scope list means "unrestricted" for that role. Nesting
84    /// changes where the member lives, not how badly losing it fails.
85    #[test]
86    fn scoped_expiring_grant_keeps_its_scope_and_expiry() {
87        let json = serde_json::json!({
88            "entry": {
89                "subject": "did:key:z6MkSubject",
90                "role": "admin",
91                "scopes": ["ctx-a", "ctx-b"],
92                "expiresAt": "2027-01-15T08:00:00Z",
93                "stepUp": { "approver": "did:key:z6MkApprover", "require": "delegated" }
94            }
95        });
96        let body: CreateAclBody = serde_json::from_value(json).unwrap();
97        assert_eq!(body.entry.scopes, vec!["ctx-a", "ctx-b"]);
98        assert!(body.entry.expires_at.is_some());
99        assert_eq!(
100            body.entry.step_up_approver().as_deref(),
101            Some("did:key:z6MkApprover")
102        );
103        assert_eq!(body.entry.step_up_require().as_deref(), Some("delegated"));
104    }
105
106    /// A misspelled member is a loud rejection, never a silent drop that would
107    /// default the entry to unrestricted scope.
108    #[test]
109    fn unknown_member_is_rejected() {
110        let json = serde_json::json!({
111            "entry": { "subject": "did:key:zA", "role": "admin", "scope": ["ctx-a"] }
112        });
113        assert!(serde_json::from_value::<CreateAclBody>(json).is_err());
114    }
115
116    /// The pre-fold flat body must not deserialize — it would arrive with no
117    /// scopes at all, which for an admin role is a super-admin.
118    #[test]
119    fn pre_fold_flat_body_is_rejected() {
120        let json = serde_json::json!({
121            "did": "did:key:zA", "role": "admin", "allowedContexts": ["ctx-a"]
122        });
123        assert!(serde_json::from_value::<CreateAclBody>(json).is_err());
124    }
125}