use std::collections::{BTreeSet, HashMap};
use super::{Role, UserId};
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub enum Action {
Select,
Insert,
Update,
Delete,
Truncate,
References,
Execute,
Usage,
All,
}
impl Action {
pub fn from_keyword(kw: &str) -> Option<Self> {
match kw.to_ascii_uppercase().as_str() {
"SELECT" => Some(Self::Select),
"INSERT" => Some(Self::Insert),
"UPDATE" => Some(Self::Update),
"DELETE" => Some(Self::Delete),
"TRUNCATE" => Some(Self::Truncate),
"REFERENCES" => Some(Self::References),
"EXECUTE" => Some(Self::Execute),
"USAGE" => Some(Self::Usage),
"ALL" => Some(Self::All),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Select => "SELECT",
Self::Insert => "INSERT",
Self::Update => "UPDATE",
Self::Delete => "DELETE",
Self::Truncate => "TRUNCATE",
Self::References => "REFERENCES",
Self::Execute => "EXECUTE",
Self::Usage => "USAGE",
Self::All => "ALL",
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub enum Resource {
Database,
Schema(String),
Table {
schema: Option<String>,
table: String,
},
Function {
schema: Option<String>,
name: String,
},
}
impl Resource {
pub fn table_from_name(name: &str) -> Self {
match name.split_once('.') {
Some((schema, table)) => Self::Table {
schema: Some(schema.to_string()),
table: table.to_string(),
},
None => Self::Table {
schema: None,
table: name.to_string(),
},
}
}
pub fn covers(&self, requested: &Resource) -> bool {
match (self, requested) {
(Resource::Database, _) => true,
(Resource::Schema(s), Resource::Table { schema, .. }) => {
schema.as_deref() == Some(s.as_str())
}
(Resource::Schema(s), Resource::Function { schema, .. }) => {
schema.as_deref() == Some(s.as_str())
}
(a, b) => a == b,
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum GrantPrincipal {
User(UserId),
Group(String),
Public,
}
impl GrantPrincipal {
pub fn as_user(&self) -> Option<&UserId> {
if let GrantPrincipal::User(u) = self {
Some(u)
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct Grant {
pub principal: GrantPrincipal,
pub resource: Resource,
pub actions: BTreeSet<Action>,
pub with_grant_option: bool,
pub granted_by: String,
pub granted_at: u128,
pub tenant: Option<String>,
pub columns: Option<Vec<String>>,
}
impl Grant {
pub fn single(
principal: GrantPrincipal,
resource: Resource,
action: Action,
granted_by: String,
granted_at: u128,
tenant: Option<String>,
) -> Self {
let mut actions = BTreeSet::new();
actions.insert(action);
Self {
principal,
resource,
actions,
with_grant_option: false,
granted_by,
granted_at,
tenant,
columns: None,
}
}
pub fn authorises(&self, action: Action, resource: &Resource, tenant: Option<&str>) -> bool {
if self.tenant.as_deref() != tenant {
return false;
}
if !self.resource.covers(resource) {
return false;
}
self.actions.contains(&action) || self.actions.contains(&Action::All)
}
}
#[derive(Debug, Clone, Default)]
pub struct UserAttributes {
pub valid_until: Option<u128>,
pub connection_limit: Option<u32>,
pub search_path: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct AuthzContext<'a> {
pub principal: &'a str,
pub effective_role: Role,
pub tenant: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub enum AuthzError {
PermissionDenied {
action: Action,
resource: Resource,
principal: String,
},
CrossTenantDenied { action: Action, principal: String },
}
impl std::fmt::Display for AuthzError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthzError::PermissionDenied {
action,
resource,
principal,
} => write!(
f,
"permission denied: principal={principal} action={a} resource={r:?}",
a = action.as_str(),
r = resource
),
AuthzError::CrossTenantDenied { action, principal } => write!(
f,
"cross-tenant denied: principal={principal} action={a}",
a = action.as_str()
),
}
}
}
impl std::error::Error for AuthzError {}
pub struct GrantsView<'a> {
pub user_grants: &'a [Grant],
pub public_grants: &'a [Grant],
}
pub fn check_grant(
ctx: &AuthzContext<'_>,
action: Action,
resource: &Resource,
grants: &GrantsView<'_>,
) -> Result<(), AuthzError> {
if ctx.effective_role == Role::Admin {
return Ok(());
}
let no_grants_at_all = grants.user_grants.is_empty() && grants.public_grants.is_empty();
if no_grants_at_all {
let allowed = match action {
Action::Select | Action::Usage | Action::Execute => ctx.effective_role >= Role::Read,
Action::Insert | Action::Update | Action::Delete | Action::Truncate => {
ctx.effective_role >= Role::Write
}
Action::References => ctx.effective_role >= Role::Read,
Action::All => false,
};
return if allowed {
Ok(())
} else {
Err(AuthzError::PermissionDenied {
action,
resource: resource.clone(),
principal: ctx.principal.to_string(),
})
};
}
let scan = |g: &Grant| g.authorises(action, resource, ctx.tenant);
if grants.user_grants.iter().any(scan) || grants.public_grants.iter().any(scan) {
return Ok(());
}
Err(AuthzError::PermissionDenied {
action,
resource: resource.clone(),
principal: ctx.principal.to_string(),
})
}
#[derive(Debug, Default, Clone)]
pub struct PermissionCache {
entries: HashMap<(Resource, Action), ()>,
}
impl PermissionCache {
pub fn build(user_grants: &[Grant], public_grants: &[Grant]) -> Self {
let mut entries: HashMap<(Resource, Action), ()> = HashMap::new();
for g in user_grants.iter().chain(public_grants.iter()) {
for a in concrete_actions(&g.actions) {
entries.insert((g.resource.clone(), a), ());
}
}
Self { entries }
}
pub fn allows(&self, resource: &Resource, action: Action) -> bool {
self.entries.contains_key(&(resource.clone(), action))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn concrete_actions(set: &BTreeSet<Action>) -> Vec<Action> {
if set.contains(&Action::All) {
return vec![
Action::Select,
Action::Insert,
Action::Update,
Action::Delete,
Action::Truncate,
Action::References,
Action::Execute,
Action::Usage,
];
}
set.iter().copied().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn t(name: &str) -> Resource {
Resource::Table {
schema: None,
table: name.into(),
}
}
fn grant_for(user: &str, res: Resource, action: Action) -> Grant {
Grant::single(
GrantPrincipal::User(UserId::platform(user)),
res,
action,
"admin".into(),
0,
None,
)
}
fn ctx<'a>(user: &'a str, role: Role) -> AuthzContext<'a> {
AuthzContext {
principal: user,
effective_role: role,
tenant: None,
}
}
#[test]
fn admin_bypasses_every_check() {
let view = GrantsView {
user_grants: &[],
public_grants: &[],
};
let ctx = ctx("root", Role::Admin);
assert!(check_grant(&ctx, Action::Delete, &t("anything"), &view).is_ok());
}
#[test]
fn legacy_fallback_when_no_grants_exist() {
let view = GrantsView {
user_grants: &[],
public_grants: &[],
};
assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &t("u"), &view).is_ok());
assert!(check_grant(&ctx("alice", Role::Read), Action::Insert, &t("u"), &view).is_err());
assert!(check_grant(&ctx("bob", Role::Write), Action::Insert, &t("u"), &view).is_ok());
}
#[test]
fn user_grant_allows_action() {
let g = grant_for("alice", t("orders"), Action::Select);
let view = GrantsView {
user_grants: std::slice::from_ref(&g),
public_grants: &[],
};
assert!(check_grant(
&ctx("alice", Role::Read),
Action::Select,
&t("orders"),
&view
)
.is_ok());
assert!(check_grant(
&ctx("alice", Role::Read),
Action::Select,
&t("hosts"),
&view
)
.is_err());
assert!(check_grant(
&ctx("alice", Role::Read),
Action::Insert,
&t("orders"),
&view
)
.is_err());
}
#[test]
fn schema_grant_covers_tables_in_schema() {
let g = Grant::single(
GrantPrincipal::User(UserId::platform("alice")),
Resource::Schema("acme".into()),
Action::Select,
"admin".into(),
0,
None,
);
let view = GrantsView {
user_grants: std::slice::from_ref(&g),
public_grants: &[],
};
let r = Resource::Table {
schema: Some("acme".into()),
table: "x".into(),
};
assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &r, &view).is_ok());
let bad = Resource::Table {
schema: Some("public".into()),
table: "x".into(),
};
assert!(check_grant(&ctx("alice", Role::Read), Action::Select, &bad, &view).is_err());
}
#[test]
fn public_grant_applies_to_everyone() {
let g = Grant::single(
GrantPrincipal::Public,
t("welcome"),
Action::Select,
"admin".into(),
0,
None,
);
let view = GrantsView {
user_grants: &[],
public_grants: std::slice::from_ref(&g),
};
assert!(check_grant(
&ctx("anyone", Role::Read),
Action::Select,
&t("welcome"),
&view
)
.is_ok());
}
#[test]
fn all_action_authorises_everything() {
let mut actions = BTreeSet::new();
actions.insert(Action::All);
let g = Grant {
principal: GrantPrincipal::User(UserId::platform("alice")),
resource: t("orders"),
actions,
with_grant_option: true,
granted_by: "admin".into(),
granted_at: 0,
tenant: None,
columns: None,
};
let view = GrantsView {
user_grants: std::slice::from_ref(&g),
public_grants: &[],
};
for a in [
Action::Select,
Action::Insert,
Action::Update,
Action::Delete,
Action::Truncate,
] {
assert!(check_grant(&ctx("alice", Role::Read), a, &t("orders"), &view).is_ok());
}
}
#[test]
fn cross_tenant_grant_does_not_match() {
let g = Grant::single(
GrantPrincipal::User(UserId::platform("alice")),
t("orders"),
Action::Select,
"admin".into(),
0,
Some("acme".into()),
);
let view = GrantsView {
user_grants: std::slice::from_ref(&g),
public_grants: &[],
};
let mut ctx = ctx("alice", Role::Read);
ctx.tenant = Some("globex");
assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_err());
ctx.tenant = Some("acme");
assert!(check_grant(&ctx, Action::Select, &t("orders"), &view).is_ok());
}
#[test]
fn permission_cache_expands_all() {
let mut actions = BTreeSet::new();
actions.insert(Action::All);
let g = Grant {
principal: GrantPrincipal::User(UserId::platform("alice")),
resource: t("orders"),
actions,
with_grant_option: false,
granted_by: "admin".into(),
granted_at: 0,
tenant: None,
columns: None,
};
let cache = PermissionCache::build(std::slice::from_ref(&g), &[]);
assert!(cache.allows(&t("orders"), Action::Select));
assert!(cache.allows(&t("orders"), Action::Insert));
assert!(cache.allows(&t("orders"), Action::Delete));
assert!(!cache.allows(&t("nope"), Action::Select));
}
#[test]
fn resource_table_from_dotted_name() {
let r = Resource::table_from_name("public.users");
assert_eq!(
r,
Resource::Table {
schema: Some("public".into()),
table: "users".into()
}
);
let r = Resource::table_from_name("users");
assert_eq!(
r,
Resource::Table {
schema: None,
table: "users".into()
}
);
}
#[test]
fn database_grant_covers_anything() {
let g = Grant::single(
GrantPrincipal::User(UserId::platform("alice")),
Resource::Database,
Action::Select,
"admin".into(),
0,
None,
);
let view = GrantsView {
user_grants: std::slice::from_ref(&g),
public_grants: &[],
};
assert!(check_grant(
&ctx("alice", Role::Read),
Action::Select,
&t("anything"),
&view
)
.is_ok());
}
}