Skip to main content

reddb_server/auth/
privileges.rs

1//! Granular RBAC: per-table/action privileges plus user attributes.
2//!
3//! RedDB's baseline auth model exposes three fixed roles (`Read` / `Write`
4//! / `Admin`). Postgres users expect finer-grained `GRANT`/`REVOKE` on
5//! individual tables, schemas, and functions, plus account attributes
6//! such as `VALID UNTIL` and `CONNECTION LIMIT`. This module adds those
7//! pieces alongside the existing role model — the legacy fast-path still
8//! applies when no grants exist for the principal (back-compat for
9//! deployments that pre-date this work).
10//!
11//! # Resolution algorithm
12//! `check_grant(ctx, action, resource)` walks the ACL in this order:
13//!
14//! 1. `Admin` role bypasses every check. (Fast path.)
15//! 2. If the principal has zero grants AND zero `Public` grants exist,
16//!    fall back to the legacy `Role::Write` / `Role::Read` rule. This is
17//!    the back-compat shim for already-deployed instances that have
18//!    not issued a single GRANT — they keep working without surprise.
19//! 3. Otherwise scan the principal's own grants, then group memberships
20//!    (reserved for future role-as-group support), then `Public` grants.
21//!    A grant matches when:
22//!      * its `Resource` equals the requested `Resource`, OR the request
23//!        is for a `Table` whose schema matches a `Schema(s)` grant, OR
24//!        a `Database` grant covers everything.
25//!      * its `actions` set contains the requested action OR `All`.
26//! 4. If any matching grant is found, allow; otherwise deny (fail-closed).
27//!
28//! # Tenant scoping
29//! Grants carry an implicit tenant that is taken from the user's tenant
30//! at GRANT time (the user record owns the tenant; see `AuthStore`).
31//! `check_grant` rejects cross-tenant matches: if the request's tenant
32//! does not equal the grant's tenant the grant is skipped. A `None`
33//! tenant on either side is treated as the global/platform tenant and
34//! only matches another `None`.
35
36use std::collections::{BTreeSet, HashMap};
37
38use super::{Role, UserId};
39
40// ---------------------------------------------------------------------------
41// Action
42// ---------------------------------------------------------------------------
43
44/// SQL action covered by a grant. Mirrors PG's privilege vocabulary.
45#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
46pub enum Action {
47    /// SELECT on a table / view.
48    Select,
49    /// INSERT on a table.
50    Insert,
51    /// UPDATE on a table.
52    Update,
53    /// DELETE on a table.
54    Delete,
55    /// TRUNCATE TABLE.
56    Truncate,
57    /// REFERENCES (FK target privilege).
58    References,
59    /// EXECUTE on a function.
60    Execute,
61    /// USAGE on a schema or sequence.
62    Usage,
63    /// All privileges (PG `ALL [PRIVILEGES]`).
64    All,
65}
66
67impl Action {
68    /// Parse a privilege keyword (case-insensitive). Returns `None` for
69    /// unrecognised tokens so the parser can produce a precise error.
70    pub fn from_keyword(kw: &str) -> Option<Self> {
71        match kw.to_ascii_uppercase().as_str() {
72            "SELECT" => Some(Self::Select),
73            "INSERT" => Some(Self::Insert),
74            "UPDATE" => Some(Self::Update),
75            "DELETE" => Some(Self::Delete),
76            "TRUNCATE" => Some(Self::Truncate),
77            "REFERENCES" => Some(Self::References),
78            "EXECUTE" => Some(Self::Execute),
79            "USAGE" => Some(Self::Usage),
80            "ALL" => Some(Self::All),
81            _ => None,
82        }
83    }
84
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Select => "SELECT",
88            Self::Insert => "INSERT",
89            Self::Update => "UPDATE",
90            Self::Delete => "DELETE",
91            Self::Truncate => "TRUNCATE",
92            Self::References => "REFERENCES",
93            Self::Execute => "EXECUTE",
94            Self::Usage => "USAGE",
95            Self::All => "ALL",
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// Resource
102// ---------------------------------------------------------------------------
103
104/// Object the grant covers. Schemas and tables form a hierarchy: a grant
105/// on `Schema("public")` implicitly covers every table in `public`.
106#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
107pub enum Resource {
108    /// Cluster-wide (PG: `ON DATABASE`).
109    Database,
110    /// All objects within a schema.
111    Schema(String),
112    /// A specific table (optionally schema-qualified).
113    Table {
114        schema: Option<String>,
115        table: String,
116    },
117    /// A function (UDF / aggregate).
118    Function {
119        schema: Option<String>,
120        name: String,
121    },
122}
123
124impl Resource {
125    /// Construct a `Table` resource from a possibly dotted name.
126    /// `"public.users"` → `Table { schema: Some("public"), table: "users" }`.
127    /// `"users"` → `Table { schema: None, table: "users" }`.
128    pub fn table_from_name(name: &str) -> Self {
129        let name = reddb_engine::storage::system_collection_names::canonical_or_self(name);
130        match name.split_once('.') {
131            Some((schema, table)) => Self::Table {
132                schema: Some(schema.to_string()),
133                table: table.to_string(),
134            },
135            None => Self::Table {
136                schema: None,
137                table: name.to_string(),
138            },
139        }
140    }
141
142    /// Does `self` (a grant resource) cover `requested`?
143    /// `Database` covers everything; `Schema(s)` covers any
144    /// `Table { schema: Some(s), .. }`; everything else requires
145    /// equality.
146    pub fn covers(&self, requested: &Resource) -> bool {
147        match (self, requested) {
148            (Resource::Database, _) => true,
149            (Resource::Schema(s), Resource::Table { schema, .. }) => {
150                schema.as_deref() == Some(s.as_str())
151            }
152            (Resource::Schema(s), Resource::Function { schema, .. }) => {
153                schema.as_deref() == Some(s.as_str())
154            }
155            (a, b) => a == b,
156        }
157    }
158}
159
160// ---------------------------------------------------------------------------
161// GrantPrincipal
162// ---------------------------------------------------------------------------
163
164/// Who the grant applies to.
165#[derive(Debug, Clone, Hash, Eq, PartialEq)]
166pub enum GrantPrincipal {
167    /// A specific user (tenant-scoped via `UserId`).
168    User(UserId),
169    /// A named group (role-as-group, future expansion).
170    Group(String),
171    /// Everyone — equivalent to PG's `PUBLIC`.
172    Public,
173}
174
175impl GrantPrincipal {
176    pub fn as_user(&self) -> Option<&UserId> {
177        if let GrantPrincipal::User(u) = self {
178            Some(u)
179        } else {
180            None
181        }
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Grant
187// ---------------------------------------------------------------------------
188
189/// A single GRANT row.
190#[derive(Debug, Clone)]
191pub struct Grant {
192    pub principal: GrantPrincipal,
193    pub resource: Resource,
194    pub actions: BTreeSet<Action>,
195    /// `WITH GRANT OPTION` — recipient may re-grant.
196    pub with_grant_option: bool,
197    /// Username of the grantor.
198    pub granted_by: String,
199    /// Timestamp (ms since epoch) for audit.
200    pub granted_at: u128,
201    /// Tenant the grant lives in. `None` = global/platform tenant.
202    pub tenant: Option<String>,
203    /// Optional column list for column-level privileges. `None`
204    /// means the grant covers all columns. Storage-only (the
205    /// AST/parser populates it; enforcement is deferred — see
206    /// the module docstring).
207    pub columns: Option<Vec<String>>,
208}
209
210impl Grant {
211    /// Convenience constructor — most callers want a single-action grant.
212    pub fn single(
213        principal: GrantPrincipal,
214        resource: Resource,
215        action: Action,
216        granted_by: String,
217        granted_at: u128,
218        tenant: Option<String>,
219    ) -> Self {
220        let mut actions = BTreeSet::new();
221        actions.insert(action);
222        Self {
223            principal,
224            resource,
225            actions,
226            with_grant_option: false,
227            granted_by,
228            granted_at,
229            tenant,
230            columns: None,
231        }
232    }
233
234    /// True iff this grant authorises `action` on `resource` for the
235    /// given `tenant`.
236    pub fn authorises(&self, action: Action, resource: &Resource, tenant: Option<&str>) -> bool {
237        if self.tenant.as_deref() != tenant {
238            return false;
239        }
240        if !self.resource.covers(resource) {
241            return false;
242        }
243        self.actions.contains(&action) || self.actions.contains(&Action::All)
244    }
245}
246
247// ---------------------------------------------------------------------------
248// UserAttributes
249// ---------------------------------------------------------------------------
250
251/// Per-user account attributes that PG exposes via `ALTER USER`. None of
252/// these are tied to the underlying password hash — they live alongside
253/// the `User` record so they can be modified without rotating credentials.
254#[derive(Debug, Clone, Default)]
255pub struct UserAttributes {
256    /// Account expiry (ms since epoch). Logins after this point are
257    /// rejected with `InvalidCredentials`.
258    pub valid_until: Option<u128>,
259    /// Maximum concurrent sessions. `None` = unlimited.
260    pub connection_limit: Option<u32>,
261    /// `SET search_path = ...` style default applied at connection time.
262    pub search_path: Option<String>,
263    /// IAM policy groups this user belongs to.
264    pub groups: Vec<String>,
265}
266
267// ---------------------------------------------------------------------------
268// AuthzContext + check_grant
269// ---------------------------------------------------------------------------
270
271/// Caller identity threaded through the privilege check.
272#[derive(Debug, Clone)]
273pub struct AuthzContext<'a> {
274    /// Username of the principal making the request.
275    pub principal: &'a str,
276    /// The principal's effective role (legacy fast-path input).
277    pub effective_role: Role,
278    /// Tenant the request runs under. `None` = global/platform tenant.
279    pub tenant: Option<&'a str>,
280}
281
282/// Privilege-check error.
283#[derive(Debug, Clone)]
284pub enum AuthzError {
285    /// Action denied by the privilege engine.
286    PermissionDenied {
287        action: Action,
288        resource: Resource,
289        principal: String,
290    },
291    /// Tenant mismatch — request tenant does not match grant tenant.
292    CrossTenantDenied { action: Action, principal: String },
293}
294
295impl std::fmt::Display for AuthzError {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            AuthzError::PermissionDenied {
299                action,
300                resource,
301                principal,
302            } => write!(
303                f,
304                "permission denied: principal={principal} action={a} resource={r:?}",
305                a = action.as_str(),
306                r = resource
307            ),
308            AuthzError::CrossTenantDenied { action, principal } => write!(
309                f,
310                "cross-tenant denied: principal={principal} action={a}",
311                a = action.as_str()
312            ),
313        }
314    }
315}
316
317impl std::error::Error for AuthzError {}
318
319/// Inputs to `check_grant`. Decoupled from `AuthStore` so unit tests
320/// can construct fixtures without booting a vault.
321pub struct GrantsView<'a> {
322    /// Grants owned directly by the principal.
323    pub user_grants: &'a [Grant],
324    /// Grants applied to PUBLIC (everyone).
325    pub public_grants: &'a [Grant],
326}
327
328/// Core privilege check. See module docstring for the resolution order.
329///
330/// **Fail-closed.** Any internal ambiguity (e.g. an unparseable resource)
331/// produces `Err(PermissionDenied)` — never an `Ok`.
332pub fn check_grant(
333    ctx: &AuthzContext<'_>,
334    action: Action,
335    resource: &Resource,
336    grants: &GrantsView<'_>,
337) -> Result<(), AuthzError> {
338    // 1. Admin bypass — keeps the existing 3-role model intact for
339    //    operators who haven't switched to per-object grants yet.
340    if ctx.effective_role == Role::Admin {
341        return Ok(());
342    }
343
344    // 2. Legacy fallback: when no grants are configured anywhere, defer
345    //    to the role-wide rule so an upgraded instance keeps working.
346    let no_grants_at_all = grants.user_grants.is_empty() && grants.public_grants.is_empty();
347    if no_grants_at_all {
348        let allowed = match action {
349            Action::Select | Action::Usage | Action::Execute => ctx.effective_role >= Role::Read,
350            Action::Insert | Action::Update | Action::Delete | Action::Truncate => {
351                ctx.effective_role >= Role::Write
352            }
353            Action::References => ctx.effective_role >= Role::Read,
354            // ALL only makes sense for Admin, which already returned above.
355            Action::All => false,
356        };
357        return if allowed {
358            Ok(())
359        } else {
360            Err(AuthzError::PermissionDenied {
361                action,
362                resource: resource.clone(),
363                principal: ctx.principal.to_string(),
364            })
365        };
366    }
367
368    // 3. Walk per-user grants, then PUBLIC grants. First match wins.
369    let scan = |g: &Grant| g.authorises(action, resource, ctx.tenant);
370    if grants.user_grants.iter().any(scan) || grants.public_grants.iter().any(scan) {
371        return Ok(());
372    }
373
374    Err(AuthzError::PermissionDenied {
375        action,
376        resource: resource.clone(),
377        principal: ctx.principal.to_string(),
378    })
379}
380
381// ---------------------------------------------------------------------------
382// Pre-resolved permission cache (per user)
383// ---------------------------------------------------------------------------
384
385/// Compact (resource, action) lookup pre-built from a user's grants
386/// + PUBLIC grants. The privilege check first probes this cache before
387///   falling back to the linear scan above. Invalidated on every
388///   GRANT / REVOKE / ALTER USER.
389#[derive(Debug, Default, Clone)]
390pub struct PermissionCache {
391    /// Set of (resource, action) tuples authorised. `Action::All` is
392    /// expanded into one entry per concrete action so lookups stay O(1).
393    entries: HashMap<(Resource, Action), ()>,
394}
395
396impl PermissionCache {
397    pub fn build(user_grants: &[Grant], public_grants: &[Grant]) -> Self {
398        let mut entries: HashMap<(Resource, Action), ()> = HashMap::new();
399        for g in user_grants.iter().chain(public_grants.iter()) {
400            for a in concrete_actions(&g.actions) {
401                entries.insert((g.resource.clone(), a), ());
402            }
403        }
404        Self { entries }
405    }
406
407    /// O(1) cache check. Returns `true` if the cache contains an exact
408    /// (resource, action) match. Caller must still consult `check_grant`
409    /// for hierarchical lookups (Schema covers Table, etc.).
410    pub fn allows(&self, resource: &Resource, action: Action) -> bool {
411        self.entries.contains_key(&(resource.clone(), action))
412    }
413
414    pub fn is_empty(&self) -> bool {
415        self.entries.is_empty()
416    }
417}
418
419fn concrete_actions(set: &BTreeSet<Action>) -> Vec<Action> {
420    if set.contains(&Action::All) {
421        return vec![
422            Action::Select,
423            Action::Insert,
424            Action::Update,
425            Action::Delete,
426            Action::Truncate,
427            Action::References,
428            Action::Execute,
429            Action::Usage,
430        ];
431    }
432    set.iter().copied().collect()
433}
434
435// ---------------------------------------------------------------------------
436// Tests
437// ---------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn t(name: &str) -> Resource {
444        Resource::Table {
445            schema: None,
446            table: name.into(),
447        }
448    }
449
450    fn grant_for(user: &str, res: Resource, action: Action) -> Grant {
451        Grant::single(
452            GrantPrincipal::User(UserId::platform(user)),
453            res,
454            action,
455            "admin".into(),
456            0,
457            None,
458        )
459    }
460
461    fn ctx<'a>(user: &'a str, role: Role) -> AuthzContext<'a> {
462        AuthzContext {
463            principal: user,
464            effective_role: role,
465            tenant: None,
466        }
467    }
468
469    #[test]
470    fn admin_bypasses_every_check() {
471        let view = GrantsView {
472            user_grants: &[],
473            public_grants: &[],
474        };
475        let ctx = ctx("root", Role::Admin);
476        assert!(check_grant(&ctx, Action::Delete, &t("anything"), &view).is_ok());
477    }
478
479    #[test]
480    fn legacy_fallback_when_no_grants_exist() {
481        let view = GrantsView {
482            user_grants: &[],
483            public_grants: &[],
484        };
485        // Read role can SELECT, can't INSERT.
486        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &t("u"), &view).is_ok());
487        assert!(check_grant(&ctx("alice", Role::Read), Action::Insert, &t("u"), &view).is_err());
488        // Write role can INSERT.
489        assert!(check_grant(&ctx("bob", Role::Write), Action::Insert, &t("u"), &view).is_ok());
490    }
491
492    #[test]
493    fn user_grant_allows_action() {
494        let g = grant_for("alice", t("orders"), Action::Select);
495        let view = GrantsView {
496            user_grants: std::slice::from_ref(&g),
497            public_grants: &[],
498        };
499        assert!(check_grant(
500            &ctx("alice", Role::Read),
501            Action::Select,
502            &t("orders"),
503            &view
504        )
505        .is_ok());
506        // Different table — denied.
507        assert!(check_grant(
508            &ctx("alice", Role::Read),
509            Action::Select,
510            &t("hosts"),
511            &view
512        )
513        .is_err());
514        // Same table, different action — denied.
515        assert!(check_grant(
516            &ctx("alice", Role::Read),
517            Action::Insert,
518            &t("orders"),
519            &view
520        )
521        .is_err());
522    }
523
524    #[test]
525    fn schema_grant_covers_tables_in_schema() {
526        let g = Grant::single(
527            GrantPrincipal::User(UserId::platform("alice")),
528            Resource::Schema("acme".into()),
529            Action::Select,
530            "admin".into(),
531            0,
532            None,
533        );
534        let view = GrantsView {
535            user_grants: std::slice::from_ref(&g),
536            public_grants: &[],
537        };
538        let r = Resource::Table {
539            schema: Some("acme".into()),
540            table: "x".into(),
541        };
542        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &r, &view).is_ok());
543        // Different schema — denied.
544        let bad = Resource::Table {
545            schema: Some("public".into()),
546            table: "x".into(),
547        };
548        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &bad, &view).is_err());
549    }
550
551    #[test]
552    fn public_grant_applies_to_everyone() {
553        let g = Grant::single(
554            GrantPrincipal::Public,
555            t("welcome"),
556            Action::Select,
557            "admin".into(),
558            0,
559            None,
560        );
561        let view = GrantsView {
562            user_grants: &[],
563            public_grants: std::slice::from_ref(&g),
564        };
565        assert!(check_grant(
566            &ctx("anyone", Role::Read),
567            Action::Select,
568            &t("welcome"),
569            &view
570        )
571        .is_ok());
572    }
573
574    #[test]
575    fn all_action_authorises_everything() {
576        let mut actions = BTreeSet::new();
577        actions.insert(Action::All);
578        let g = Grant {
579            principal: GrantPrincipal::User(UserId::platform("alice")),
580            resource: t("orders"),
581            actions,
582            with_grant_option: true,
583            granted_by: "admin".into(),
584            granted_at: 0,
585            tenant: None,
586            columns: None,
587        };
588        let view = GrantsView {
589            user_grants: std::slice::from_ref(&g),
590            public_grants: &[],
591        };
592        for a in [
593            Action::Select,
594            Action::Insert,
595            Action::Update,
596            Action::Delete,
597            Action::Truncate,
598        ] {
599            assert!(check_grant(&ctx("alice", Role::Read), a, &t("orders"), &view).is_ok());
600        }
601    }
602
603    #[test]
604    fn cross_tenant_grant_does_not_match() {
605        let g = Grant::single(
606            GrantPrincipal::User(UserId::platform("alice")),
607            t("orders"),
608            Action::Select,
609            "admin".into(),
610            0,
611            Some("acme".into()),
612        );
613        let view = GrantsView {
614            user_grants: std::slice::from_ref(&g),
615            public_grants: &[],
616        };
617        let mut ctx = ctx("alice", Role::Read);
618        ctx.tenant = Some("globex");
619        assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_err());
620        ctx.tenant = Some("acme");
621        assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_ok());
622    }
623
624    #[test]
625    fn permission_cache_expands_all() {
626        let mut actions = BTreeSet::new();
627        actions.insert(Action::All);
628        let g = Grant {
629            principal: GrantPrincipal::User(UserId::platform("alice")),
630            resource: t("orders"),
631            actions,
632            with_grant_option: false,
633            granted_by: "admin".into(),
634            granted_at: 0,
635            tenant: None,
636            columns: None,
637        };
638        let cache = PermissionCache::build(std::slice::from_ref(&g), &[]);
639        assert!(cache.allows(&t("orders"), Action::Select));
640        assert!(cache.allows(&t("orders"), Action::Insert));
641        assert!(cache.allows(&t("orders"), Action::Delete));
642        assert!(!cache.allows(&t("nope"), Action::Select));
643    }
644
645    #[test]
646    fn resource_table_from_dotted_name() {
647        let r = Resource::table_from_name("public.users");
648        assert_eq!(
649            r,
650            Resource::Table {
651                schema: Some("public".into()),
652                table: "users".into()
653            }
654        );
655        let r = Resource::table_from_name("users");
656        assert_eq!(
657            r,
658            Resource::Table {
659                schema: None,
660                table: "users".into()
661            }
662        );
663    }
664
665    #[test]
666    fn database_grant_covers_anything() {
667        let g = Grant::single(
668            GrantPrincipal::User(UserId::platform("alice")),
669            Resource::Database,
670            Action::Select,
671            "admin".into(),
672            0,
673            None,
674        );
675        let view = GrantsView {
676            user_grants: std::slice::from_ref(&g),
677            public_grants: &[],
678        };
679        assert!(check_grant(
680            &ctx("alice", Role::Read),
681            Action::Select,
682            &t("anything"),
683            &view
684        )
685        .is_ok());
686    }
687}