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 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 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#[derive(Debug, Clone, Hash, Eq, PartialEq)]
165pub enum GrantPrincipal {
166 User(UserId),
168 Group(String),
170 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#[derive(Debug, Clone)]
190pub struct Grant {
191 pub principal: GrantPrincipal,
192 pub resource: Resource,
193 pub actions: BTreeSet<Action>,
194 pub with_grant_option: bool,
196 pub granted_by: String,
198 pub granted_at: u128,
200 pub tenant: Option<String>,
202 pub columns: Option<Vec<String>>,
207}
208
209impl Grant {
210 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 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#[derive(Debug, Clone, Default)]
254pub struct UserAttributes {
255 pub valid_until: Option<u128>,
258 pub connection_limit: Option<u32>,
260 pub search_path: Option<String>,
262 pub groups: Vec<String>,
264}
265
266#[derive(Debug, Clone)]
272pub struct AuthzContext<'a> {
273 pub principal: &'a str,
275 pub effective_role: Role,
277 pub tenant: Option<&'a str>,
279}
280
281#[derive(Debug, Clone)]
283pub enum AuthzError {
284 PermissionDenied {
286 action: Action,
287 resource: Resource,
288 principal: String,
289 },
290 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
318pub struct GrantsView<'a> {
321 pub user_grants: &'a [Grant],
323 pub public_grants: &'a [Grant],
325}
326
327pub fn check_grant(
332 ctx: &AuthzContext<'_>,
333 action: Action,
334 resource: &Resource,
335 grants: &GrantsView<'_>,
336) -> Result<(), AuthzError> {
337 if ctx.effective_role == Role::Admin {
340 return Ok(());
341 }
342
343 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 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 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#[derive(Debug, Default, Clone)]
389pub struct PermissionCache {
390 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 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#[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 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 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 assert!(check_grant(
507 &ctx("alice", Role::Read),
508 Action::Select,
509 &t("hosts"),
510 &view
511 )
512 .is_err());
513 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 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}