Skip to main content

wasm_dbms_api/dbms/
acl.rs

1// Rust guideline compliant 2026-04-27
2// X-WHERE-CLAUSE, M-PUBLIC-DEBUG, M-CANONICAL-DOCS
3
4//! Permission types backing the granular ACL.
5//!
6//! These types are reused by the storage layer (`wasm-dbms-memory::acl`),
7//! the engine (`wasm-dbms::Dbms`) and the IC canister surface. They live
8//! in `wasm-dbms-api` so that [`crate::error::DbmsError::AccessDenied`]
9//! can reference them without pulling in the memory crate.
10
11use bitflags::bitflags;
12use serde::{Deserialize, Serialize};
13
14use crate::dbms::table::TableFingerprint;
15
16bitflags! {
17    /// Per-table permission bits.
18    ///
19    /// Bits combine via the `BitOr`/`BitAnd` operators provided by
20    /// [`bitflags`]. The wire encoding is a single `u8`, mirrored on the
21    /// IC canister surface as a Candid `nat8`.
22    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
23    pub struct TablePerms: u8 {
24        /// Permission to run `SELECT` / `aggregate` against the table.
25        const READ   = 0b0001;
26        /// Permission to run `INSERT` against the table.
27        const INSERT = 0b0010;
28        /// Permission to run `UPDATE` against the table.
29        const UPDATE = 0b0100;
30        /// Permission to run `DELETE` against the table.
31        const DELETE = 0b1000;
32    }
33}
34
35#[cfg(feature = "candid")]
36impl candid::CandidType for TablePerms {
37    fn _ty() -> candid::types::Type {
38        candid::types::TypeInner::Nat8.into()
39    }
40
41    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
42    where
43        S: candid::types::Serializer,
44    {
45        serializer.serialize_nat8(self.bits())
46    }
47}
48
49/// Marker passed in [`crate::error::DbmsError::AccessDenied`] to describe
50/// the perm that was missing.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "candid", derive(candid::CandidType))]
53pub enum RequiredPerm {
54    /// A specific table permission set was required.
55    Table(TablePerms),
56    /// The `admin` bypass flag was required.
57    Admin,
58    /// The `manage_acl` operational flag was required.
59    ManageAcl,
60    /// The `migrate` operational flag was required.
61    Migrate,
62}
63
64/// Effective permission set carried by a single identity.
65///
66/// `per_table` is encoded as a `Vec<(TableFingerprint, TablePerms)>` so
67/// the type maps cleanly onto Candid (no `HashMap` on the wire). Lookup
68/// happens via linear scan; the table count is bounded by the schema.
69#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[cfg_attr(feature = "candid", derive(candid::CandidType))]
71pub struct IdentityPerms {
72    /// Bypass all table checks. Does NOT imply `manage_acl` or `migrate`.
73    pub admin: bool,
74    /// Permission to grant/revoke perms and add/remove identities.
75    pub manage_acl: bool,
76    /// Permission to run `Dbms::migrate()`.
77    pub migrate: bool,
78    /// Table perms applied to every table. Unioned with `per_table`.
79    pub all_tables: TablePerms,
80    /// Per-table perms. Additive over `all_tables`.
81    pub per_table: Vec<(TableFingerprint, TablePerms)>,
82}
83
84impl IdentityPerms {
85    /// Fully-permissive perms, used by `NoAccessControl`.
86    pub fn fully_permissive() -> Self {
87        Self {
88            admin: true,
89            manage_acl: true,
90            migrate: true,
91            all_tables: TablePerms::all(),
92            per_table: Vec::new(),
93        }
94    }
95
96    /// Returns whether this identity is granted `required` on `table`.
97    pub fn grants_table(&self, table: TableFingerprint, required: TablePerms) -> bool {
98        if self.admin {
99            return true;
100        }
101        let union = self.all_tables | self.lookup(table);
102        union.contains(required)
103    }
104
105    /// Returns the perms entry for `table`, or empty if not present.
106    fn lookup(&self, table: TableFingerprint) -> TablePerms {
107        self.per_table
108            .iter()
109            .find(|(t, _)| *t == table)
110            .map(|(_, p)| *p)
111            .unwrap_or_default()
112    }
113
114    /// Applies `grant` in place. Idempotent.
115    pub fn apply_grant(&mut self, grant: PermGrant) {
116        match grant {
117            PermGrant::Admin => self.admin = true,
118            PermGrant::ManageAcl => self.manage_acl = true,
119            PermGrant::Migrate => self.migrate = true,
120            PermGrant::AllTables(p) => self.all_tables |= p,
121            PermGrant::Table(t, p) => match self.per_table.iter_mut().find(|(tt, _)| *tt == t) {
122                Some((_, existing)) => *existing |= p,
123                None => self.per_table.push((t, p)),
124            },
125        }
126    }
127
128    /// Applies `revoke` in place. Idempotent. Removes empty per-table entries.
129    pub fn apply_revoke(&mut self, revoke: PermRevoke) {
130        match revoke {
131            PermRevoke::Admin => self.admin = false,
132            PermRevoke::ManageAcl => self.manage_acl = false,
133            PermRevoke::Migrate => self.migrate = false,
134            PermRevoke::AllTables(p) => self.all_tables.remove(p),
135            PermRevoke::Table(t, p) => {
136                if let Some(pos) = self.per_table.iter().position(|(tt, _)| *tt == t) {
137                    self.per_table[pos].1.remove(p);
138                    if self.per_table[pos].1.is_empty() {
139                        self.per_table.swap_remove(pos);
140                    }
141                }
142            }
143        }
144    }
145
146    /// True when the identity carries no perms whatsoever.
147    pub fn is_empty(&self) -> bool {
148        !self.admin
149            && !self.manage_acl
150            && !self.migrate
151            && self.all_tables.is_empty()
152            && self.per_table.is_empty()
153    }
154}
155
156/// Grant action.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158#[cfg_attr(feature = "candid", derive(candid::CandidType))]
159pub enum PermGrant {
160    /// Grant the `admin` bypass flag.
161    Admin,
162    /// Grant the `manage_acl` operational flag.
163    ManageAcl,
164    /// Grant the `migrate` operational flag.
165    Migrate,
166    /// Grant the given perm bits on every table.
167    AllTables(TablePerms),
168    /// Grant the given perm bits on a specific table.
169    Table(TableFingerprint, TablePerms),
170}
171
172/// Revoke action — symmetric to [`PermGrant`].
173#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "candid", derive(candid::CandidType))]
175pub enum PermRevoke {
176    /// Revoke the `admin` bypass flag.
177    Admin,
178    /// Revoke the `manage_acl` operational flag.
179    ManageAcl,
180    /// Revoke the `migrate` operational flag.
181    Migrate,
182    /// Revoke the given perm bits from every table (does not affect
183    /// per-table grants).
184    AllTables(TablePerms),
185    /// Revoke the given perm bits from a specific table.
186    Table(TableFingerprint, TablePerms),
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::dbms::table::fingerprint_for_name;
193
194    fn fp(name: &str) -> TableFingerprint {
195        fingerprint_for_name(name)
196    }
197
198    #[test]
199    fn test_admin_bypasses_table_check() {
200        let mut p = IdentityPerms::default();
201        p.admin = true;
202        assert!(p.grants_table(fp("users"), TablePerms::DELETE));
203    }
204
205    #[test]
206    fn test_all_tables_grant_unions_with_per_table() {
207        let mut p = IdentityPerms::default();
208        p.all_tables = TablePerms::READ;
209        p.apply_grant(PermGrant::Table(
210            fp("users"),
211            TablePerms::INSERT | TablePerms::UPDATE,
212        ));
213        assert!(p.grants_table(fp("users"), TablePerms::READ));
214        assert!(p.grants_table(fp("users"), TablePerms::INSERT));
215        assert!(!p.grants_table(fp("users"), TablePerms::DELETE));
216        assert!(p.grants_table(fp("posts"), TablePerms::READ));
217        assert!(!p.grants_table(fp("posts"), TablePerms::INSERT));
218    }
219
220    #[test]
221    fn test_apply_grant_is_idempotent() {
222        let mut p = IdentityPerms::default();
223        p.apply_grant(PermGrant::Admin);
224        p.apply_grant(PermGrant::Admin);
225        assert!(p.admin);
226        p.apply_grant(PermGrant::Table(fp("users"), TablePerms::READ));
227        p.apply_grant(PermGrant::Table(fp("users"), TablePerms::READ));
228        assert_eq!(p.per_table.len(), 1);
229        assert_eq!(p.per_table[0], (fp("users"), TablePerms::READ));
230    }
231
232    #[test]
233    fn test_revoke_partial_table_bits() {
234        let mut p = IdentityPerms::default();
235        p.apply_grant(PermGrant::Table(
236            fp("users"),
237            TablePerms::READ | TablePerms::INSERT | TablePerms::DELETE,
238        ));
239        p.apply_revoke(PermRevoke::Table(
240            fp("users"),
241            TablePerms::INSERT | TablePerms::DELETE,
242        ));
243        assert_eq!(p.per_table[0].1, TablePerms::READ);
244    }
245
246    #[test]
247    fn test_revoke_all_table_bits_removes_entry() {
248        let mut p = IdentityPerms::default();
249        p.apply_grant(PermGrant::Table(fp("users"), TablePerms::READ));
250        p.apply_revoke(PermRevoke::Table(fp("users"), TablePerms::READ));
251        assert!(p.per_table.is_empty());
252    }
253
254    #[test]
255    fn test_admin_does_not_imply_manage_acl_or_migrate() {
256        let mut p = IdentityPerms::default();
257        p.admin = true;
258        assert!(!p.manage_acl);
259        assert!(!p.migrate);
260    }
261
262    #[test]
263    fn test_fully_permissive_grants_everything() {
264        let p = IdentityPerms::fully_permissive();
265        assert!(p.admin && p.manage_acl && p.migrate);
266        assert!(p.grants_table(fp("anything"), TablePerms::all()));
267    }
268
269    #[test]
270    fn test_is_empty_after_revoking_all() {
271        let mut p = IdentityPerms::default();
272        p.apply_grant(PermGrant::Admin);
273        assert!(!p.is_empty());
274        p.apply_revoke(PermRevoke::Admin);
275        assert!(p.is_empty());
276    }
277}