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}