1use std::collections::{BTreeSet, HashMap};
37
38use super::{Role, UserId};
39
40#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
46pub enum Action {
47 Select,
49 Insert,
51 Update,
53 Delete,
55 Truncate,
57 References,
59 Execute,
61 Usage,
63 All,
65}
66
67impl Action {
68 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#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
107pub enum Resource {
108 Database,
110 Schema(String),
112 Table {
114 schema: Option<String>,
115 table: String,
116 },
117 Function {
119 schema: Option<String>,
120 name: String,
121 },
122}
123
124impl Resource {
125 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 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#[derive(Debug, Clone, Hash, Eq, PartialEq)]
166pub enum GrantPrincipal {
167 User(UserId),
169 Group(String),
171 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#[derive(Debug, Clone)]
191pub struct Grant {
192 pub principal: GrantPrincipal,
193 pub resource: Resource,
194 pub actions: BTreeSet<Action>,
195 pub with_grant_option: bool,
197 pub granted_by: String,
199 pub granted_at: u128,
201 pub tenant: Option<String>,
203 pub columns: Option<Vec<String>>,
208}
209
210impl Grant {
211 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 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#[derive(Debug, Clone, Default)]
255pub struct UserAttributes {
256 pub valid_until: Option<u128>,
259 pub connection_limit: Option<u32>,
261 pub search_path: Option<String>,
263 pub groups: Vec<String>,
265}
266
267#[derive(Debug, Clone)]
273pub struct AuthzContext<'a> {
274 pub principal: &'a str,
276 pub effective_role: Role,
278 pub tenant: Option<&'a str>,
280}
281
282#[derive(Debug, Clone)]
284pub enum AuthzError {
285 PermissionDenied {
287 action: Action,
288 resource: Resource,
289 principal: String,
290 },
291 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
319pub struct GrantsView<'a> {
322 pub user_grants: &'a [Grant],
324 pub public_grants: &'a [Grant],
326}
327
328pub fn check_grant(
333 ctx: &AuthzContext<'_>,
334 action: Action,
335 resource: &Resource,
336 grants: &GrantsView<'_>,
337) -> Result<(), AuthzError> {
338 if ctx.effective_role == Role::Admin {
341 return Ok(());
342 }
343
344 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 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 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#[derive(Debug, Default, Clone)]
390pub struct PermissionCache {
391 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 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#[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 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 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 assert!(check_grant(
508 &ctx("alice", Role::Read),
509 Action::Select,
510 &t("hosts"),
511 &view
512 )
513 .is_err());
514 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 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}