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        match name.split_once('.') {
130            Some((schema, table)) => Self::Table {
131                schema: Some(schema.to_string()),
132                table: table.to_string(),
133            },
134            None => Self::Table {
135                schema: None,
136                table: name.to_string(),
137            },
138        }
139    }
140
141    /// Does `self` (a grant resource) cover `requested`?
142    /// `Database` covers everything; `Schema(s)` covers any
143    /// `Table { schema: Some(s), .. }`; everything else requires
144    /// equality.
145    pub fn covers(&self, requested: &Resource) -> bool {
146        match (self, requested) {
147            (Resource::Database, _) => true,
148            (Resource::Schema(s), Resource::Table { schema, .. }) => {
149                schema.as_deref() == Some(s.as_str())
150            }
151            (Resource::Schema(s), Resource::Function { schema, .. }) => {
152                schema.as_deref() == Some(s.as_str())
153            }
154            (a, b) => a == b,
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// GrantPrincipal
161// ---------------------------------------------------------------------------
162
163/// Who the grant applies to.
164#[derive(Debug, Clone, Hash, Eq, PartialEq)]
165pub enum GrantPrincipal {
166    /// A specific user (tenant-scoped via `UserId`).
167    User(UserId),
168    /// A named group (role-as-group, future expansion).
169    Group(String),
170    /// Everyone — equivalent to PG's `PUBLIC`.
171    Public,
172}
173
174impl GrantPrincipal {
175    pub fn as_user(&self) -> Option<&UserId> {
176        if let GrantPrincipal::User(u) = self {
177            Some(u)
178        } else {
179            None
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Grant
186// ---------------------------------------------------------------------------
187
188/// A single GRANT row.
189#[derive(Debug, Clone)]
190pub struct Grant {
191    pub principal: GrantPrincipal,
192    pub resource: Resource,
193    pub actions: BTreeSet<Action>,
194    /// `WITH GRANT OPTION` — recipient may re-grant.
195    pub with_grant_option: bool,
196    /// Username of the grantor.
197    pub granted_by: String,
198    /// Timestamp (ms since epoch) for audit.
199    pub granted_at: u128,
200    /// Tenant the grant lives in. `None` = global/platform tenant.
201    pub tenant: Option<String>,
202    /// Optional column list for column-level privileges. `None`
203    /// means the grant covers all columns. Storage-only (the
204    /// AST/parser populates it; enforcement is deferred — see
205    /// the module docstring).
206    pub columns: Option<Vec<String>>,
207}
208
209impl Grant {
210    /// Convenience constructor — most callers want a single-action grant.
211    pub fn single(
212        principal: GrantPrincipal,
213        resource: Resource,
214        action: Action,
215        granted_by: String,
216        granted_at: u128,
217        tenant: Option<String>,
218    ) -> Self {
219        let mut actions = BTreeSet::new();
220        actions.insert(action);
221        Self {
222            principal,
223            resource,
224            actions,
225            with_grant_option: false,
226            granted_by,
227            granted_at,
228            tenant,
229            columns: None,
230        }
231    }
232
233    /// True iff this grant authorises `action` on `resource` for the
234    /// given `tenant`.
235    pub fn authorises(&self, action: Action, resource: &Resource, tenant: Option<&str>) -> bool {
236        if self.tenant.as_deref() != tenant {
237            return false;
238        }
239        if !self.resource.covers(resource) {
240            return false;
241        }
242        self.actions.contains(&action) || self.actions.contains(&Action::All)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// UserAttributes
248// ---------------------------------------------------------------------------
249
250/// Per-user account attributes that PG exposes via `ALTER USER`. None of
251/// these are tied to the underlying password hash — they live alongside
252/// the `User` record so they can be modified without rotating credentials.
253#[derive(Debug, Clone, Default)]
254pub struct UserAttributes {
255    /// Account expiry (ms since epoch). Logins after this point are
256    /// rejected with `InvalidCredentials`.
257    pub valid_until: Option<u128>,
258    /// Maximum concurrent sessions. `None` = unlimited.
259    pub connection_limit: Option<u32>,
260    /// `SET search_path = ...` style default applied at connection time.
261    pub search_path: Option<String>,
262    /// IAM policy groups this user belongs to.
263    pub groups: Vec<String>,
264}
265
266// ---------------------------------------------------------------------------
267// AuthzContext + check_grant
268// ---------------------------------------------------------------------------
269
270/// Caller identity threaded through the privilege check.
271#[derive(Debug, Clone)]
272pub struct AuthzContext<'a> {
273    /// Username of the principal making the request.
274    pub principal: &'a str,
275    /// The principal's effective role (legacy fast-path input).
276    pub effective_role: Role,
277    /// Tenant the request runs under. `None` = global/platform tenant.
278    pub tenant: Option<&'a str>,
279}
280
281/// Privilege-check error.
282#[derive(Debug, Clone)]
283pub enum AuthzError {
284    /// Action denied by the privilege engine.
285    PermissionDenied {
286        action: Action,
287        resource: Resource,
288        principal: String,
289    },
290    /// Tenant mismatch — request tenant does not match grant tenant.
291    CrossTenantDenied { action: Action, principal: String },
292}
293
294impl std::fmt::Display for AuthzError {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        match self {
297            AuthzError::PermissionDenied {
298                action,
299                resource,
300                principal,
301            } => write!(
302                f,
303                "permission denied: principal={principal} action={a} resource={r:?}",
304                a = action.as_str(),
305                r = resource
306            ),
307            AuthzError::CrossTenantDenied { action, principal } => write!(
308                f,
309                "cross-tenant denied: principal={principal} action={a}",
310                a = action.as_str()
311            ),
312        }
313    }
314}
315
316impl std::error::Error for AuthzError {}
317
318/// Inputs to `check_grant`. Decoupled from `AuthStore` so unit tests
319/// can construct fixtures without booting a vault.
320pub struct GrantsView<'a> {
321    /// Grants owned directly by the principal.
322    pub user_grants: &'a [Grant],
323    /// Grants applied to PUBLIC (everyone).
324    pub public_grants: &'a [Grant],
325}
326
327/// Core privilege check. See module docstring for the resolution order.
328///
329/// **Fail-closed.** Any internal ambiguity (e.g. an unparseable resource)
330/// produces `Err(PermissionDenied)` — never an `Ok`.
331pub fn check_grant(
332    ctx: &AuthzContext<'_>,
333    action: Action,
334    resource: &Resource,
335    grants: &GrantsView<'_>,
336) -> Result<(), AuthzError> {
337    // 1. Admin bypass — keeps the existing 3-role model intact for
338    //    operators who haven't switched to per-object grants yet.
339    if ctx.effective_role == Role::Admin {
340        return Ok(());
341    }
342
343    // 2. Legacy fallback: when no grants are configured anywhere, defer
344    //    to the role-wide rule so an upgraded instance keeps working.
345    let no_grants_at_all = grants.user_grants.is_empty() && grants.public_grants.is_empty();
346    if no_grants_at_all {
347        let allowed = match action {
348            Action::Select | Action::Usage | Action::Execute => ctx.effective_role >= Role::Read,
349            Action::Insert | Action::Update | Action::Delete | Action::Truncate => {
350                ctx.effective_role >= Role::Write
351            }
352            Action::References => ctx.effective_role >= Role::Read,
353            // ALL only makes sense for Admin, which already returned above.
354            Action::All => false,
355        };
356        return if allowed {
357            Ok(())
358        } else {
359            Err(AuthzError::PermissionDenied {
360                action,
361                resource: resource.clone(),
362                principal: ctx.principal.to_string(),
363            })
364        };
365    }
366
367    // 3. Walk per-user grants, then PUBLIC grants. First match wins.
368    let scan = |g: &Grant| g.authorises(action, resource, ctx.tenant);
369    if grants.user_grants.iter().any(scan) || grants.public_grants.iter().any(scan) {
370        return Ok(());
371    }
372
373    Err(AuthzError::PermissionDenied {
374        action,
375        resource: resource.clone(),
376        principal: ctx.principal.to_string(),
377    })
378}
379
380// ---------------------------------------------------------------------------
381// Pre-resolved permission cache (per user)
382// ---------------------------------------------------------------------------
383
384/// Compact (resource, action) lookup pre-built from a user's grants
385/// + PUBLIC grants. The privilege check first probes this cache before
386///   falling back to the linear scan above. Invalidated on every
387///   GRANT / REVOKE / ALTER USER.
388#[derive(Debug, Default, Clone)]
389pub struct PermissionCache {
390    /// Set of (resource, action) tuples authorised. `Action::All` is
391    /// expanded into one entry per concrete action so lookups stay O(1).
392    entries: HashMap<(Resource, Action), ()>,
393}
394
395impl PermissionCache {
396    pub fn build(user_grants: &[Grant], public_grants: &[Grant]) -> Self {
397        let mut entries: HashMap<(Resource, Action), ()> = HashMap::new();
398        for g in user_grants.iter().chain(public_grants.iter()) {
399            for a in concrete_actions(&g.actions) {
400                entries.insert((g.resource.clone(), a), ());
401            }
402        }
403        Self { entries }
404    }
405
406    /// O(1) cache check. Returns `true` if the cache contains an exact
407    /// (resource, action) match. Caller must still consult `check_grant`
408    /// for hierarchical lookups (Schema covers Table, etc.).
409    pub fn allows(&self, resource: &Resource, action: Action) -> bool {
410        self.entries.contains_key(&(resource.clone(), action))
411    }
412
413    pub fn is_empty(&self) -> bool {
414        self.entries.is_empty()
415    }
416}
417
418fn concrete_actions(set: &BTreeSet<Action>) -> Vec<Action> {
419    if set.contains(&Action::All) {
420        return vec![
421            Action::Select,
422            Action::Insert,
423            Action::Update,
424            Action::Delete,
425            Action::Truncate,
426            Action::References,
427            Action::Execute,
428            Action::Usage,
429        ];
430    }
431    set.iter().copied().collect()
432}
433
434// ---------------------------------------------------------------------------
435// Tests
436// ---------------------------------------------------------------------------
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn t(name: &str) -> Resource {
443        Resource::Table {
444            schema: None,
445            table: name.into(),
446        }
447    }
448
449    fn grant_for(user: &str, res: Resource, action: Action) -> Grant {
450        Grant::single(
451            GrantPrincipal::User(UserId::platform(user)),
452            res,
453            action,
454            "admin".into(),
455            0,
456            None,
457        )
458    }
459
460    fn ctx<'a>(user: &'a str, role: Role) -> AuthzContext<'a> {
461        AuthzContext {
462            principal: user,
463            effective_role: role,
464            tenant: None,
465        }
466    }
467
468    #[test]
469    fn admin_bypasses_every_check() {
470        let view = GrantsView {
471            user_grants: &[],
472            public_grants: &[],
473        };
474        let ctx = ctx("root", Role::Admin);
475        assert!(check_grant(&ctx, Action::Delete, &t("anything"), &view).is_ok());
476    }
477
478    #[test]
479    fn legacy_fallback_when_no_grants_exist() {
480        let view = GrantsView {
481            user_grants: &[],
482            public_grants: &[],
483        };
484        // Read role can SELECT, can't INSERT.
485        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &t("u"), &view).is_ok());
486        assert!(check_grant(&ctx("alice", Role::Read), Action::Insert, &t("u"), &view).is_err());
487        // Write role can INSERT.
488        assert!(check_grant(&ctx("bob", Role::Write), Action::Insert, &t("u"), &view).is_ok());
489    }
490
491    #[test]
492    fn user_grant_allows_action() {
493        let g = grant_for("alice", t("orders"), Action::Select);
494        let view = GrantsView {
495            user_grants: std::slice::from_ref(&g),
496            public_grants: &[],
497        };
498        assert!(check_grant(
499            &ctx("alice", Role::Read),
500            Action::Select,
501            &t("orders"),
502            &view
503        )
504        .is_ok());
505        // Different table — denied.
506        assert!(check_grant(
507            &ctx("alice", Role::Read),
508            Action::Select,
509            &t("hosts"),
510            &view
511        )
512        .is_err());
513        // Same table, different action — denied.
514        assert!(check_grant(
515            &ctx("alice", Role::Read),
516            Action::Insert,
517            &t("orders"),
518            &view
519        )
520        .is_err());
521    }
522
523    #[test]
524    fn schema_grant_covers_tables_in_schema() {
525        let g = Grant::single(
526            GrantPrincipal::User(UserId::platform("alice")),
527            Resource::Schema("acme".into()),
528            Action::Select,
529            "admin".into(),
530            0,
531            None,
532        );
533        let view = GrantsView {
534            user_grants: std::slice::from_ref(&g),
535            public_grants: &[],
536        };
537        let r = Resource::Table {
538            schema: Some("acme".into()),
539            table: "x".into(),
540        };
541        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &r, &view).is_ok());
542        // Different schema — denied.
543        let bad = Resource::Table {
544            schema: Some("public".into()),
545            table: "x".into(),
546        };
547        assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &bad, &view).is_err());
548    }
549
550    #[test]
551    fn public_grant_applies_to_everyone() {
552        let g = Grant::single(
553            GrantPrincipal::Public,
554            t("welcome"),
555            Action::Select,
556            "admin".into(),
557            0,
558            None,
559        );
560        let view = GrantsView {
561            user_grants: &[],
562            public_grants: std::slice::from_ref(&g),
563        };
564        assert!(check_grant(
565            &ctx("anyone", Role::Read),
566            Action::Select,
567            &t("welcome"),
568            &view
569        )
570        .is_ok());
571    }
572
573    #[test]
574    fn all_action_authorises_everything() {
575        let mut actions = BTreeSet::new();
576        actions.insert(Action::All);
577        let g = Grant {
578            principal: GrantPrincipal::User(UserId::platform("alice")),
579            resource: t("orders"),
580            actions,
581            with_grant_option: true,
582            granted_by: "admin".into(),
583            granted_at: 0,
584            tenant: None,
585            columns: None,
586        };
587        let view = GrantsView {
588            user_grants: std::slice::from_ref(&g),
589            public_grants: &[],
590        };
591        for a in [
592            Action::Select,
593            Action::Insert,
594            Action::Update,
595            Action::Delete,
596            Action::Truncate,
597        ] {
598            assert!(check_grant(&ctx("alice", Role::Read), a, &t("orders"), &view).is_ok());
599        }
600    }
601
602    #[test]
603    fn cross_tenant_grant_does_not_match() {
604        let g = Grant::single(
605            GrantPrincipal::User(UserId::platform("alice")),
606            t("orders"),
607            Action::Select,
608            "admin".into(),
609            0,
610            Some("acme".into()),
611        );
612        let view = GrantsView {
613            user_grants: std::slice::from_ref(&g),
614            public_grants: &[],
615        };
616        let mut ctx = ctx("alice", Role::Read);
617        ctx.tenant = Some("globex");
618        assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_err());
619        ctx.tenant = Some("acme");
620        assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_ok());
621    }
622
623    #[test]
624    fn permission_cache_expands_all() {
625        let mut actions = BTreeSet::new();
626        actions.insert(Action::All);
627        let g = Grant {
628            principal: GrantPrincipal::User(UserId::platform("alice")),
629            resource: t("orders"),
630            actions,
631            with_grant_option: false,
632            granted_by: "admin".into(),
633            granted_at: 0,
634            tenant: None,
635            columns: None,
636        };
637        let cache = PermissionCache::build(std::slice::from_ref(&g), &[]);
638        assert!(cache.allows(&t("orders"), Action::Select));
639        assert!(cache.allows(&t("orders"), Action::Insert));
640        assert!(cache.allows(&t("orders"), Action::Delete));
641        assert!(!cache.allows(&t("nope"), Action::Select));
642    }
643
644    #[test]
645    fn resource_table_from_dotted_name() {
646        let r = Resource::table_from_name("public.users");
647        assert_eq!(
648            r,
649            Resource::Table {
650                schema: Some("public".into()),
651                table: "users".into()
652            }
653        );
654        let r = Resource::table_from_name("users");
655        assert_eq!(
656            r,
657            Resource::Table {
658                schema: None,
659                table: "users".into()
660            }
661        );
662    }
663
664    #[test]
665    fn database_grant_covers_anything() {
666        let g = Grant::single(
667            GrantPrincipal::User(UserId::platform("alice")),
668            Resource::Database,
669            Action::Select,
670            "admin".into(),
671            0,
672            None,
673        );
674        let view = GrantsView {
675            user_grants: std::slice::from_ref(&g),
676            public_grants: &[],
677        };
678        assert!(check_grant(
679            &ctx("alice", Role::Read),
680            Action::Select,
681            &t("anything"),
682            &view
683        )
684        .is_ok());
685    }
686}