use std::collections::{HashMap, HashSet};
use std::fmt;
use std::future::{Future, IntoFuture};
use std::pin::Pin;
use super::User;
use crate::db::{DateTime, Db, Migration, Model, Query, ToDbValue, now, sql};
use crate::{Module, Registry, Result, Routes};
const MIGRATIONS: &[Migration] = &[
crate::db::framework_migration!(
"permissions",
"00010101000500_create_roles_and_permissions_tables"
),
Migration::new(
"00010101000510_add_scope_to_role_user",
include_str!("../../migrations/permissions/00010101000510_add_scope_to_role_user.up.sql"),
Some(include_str!(
"../../migrations/permissions/00010101000510_add_scope_to_role_user.down.sql"
)),
)
.postgres(
include_str!(
"../../migrations/permissions/00010101000510_add_scope_to_role_user.postgres.up.sql"
),
Some(include_str!(
"../../migrations/permissions/00010101000510_add_scope_to_role_user.postgres.down.sql"
)),
),
];
pub struct Permissions;
impl Module for Permissions {
fn name(&self) -> &'static str {
"permissions"
}
fn migrations(&self) -> &'static [Migration] {
MIGRATIONS
}
fn routes(&self) -> Routes {
Routes::new()
}
fn register(&self, app: &mut Registry) {
app.permissions = true;
app.command(
"permissions:prune",
"Delete role assignments that ended more than --days ago (default 30)",
|args, state| async move {
let days: u64 = args
.value("--days")
.unwrap_or("30")
.parse()
.map_err(|_| anyhow::anyhow!("--days takes a number of days"))?;
let age = std::time::Duration::from_secs(days * 24 * 60 * 60);
let pruned = prune_ended_assignments(&state.db, age).await?;
println!("Deleted {pruned} ended role assignments.");
Ok(())
},
);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize)]
pub struct Scope {
kind: String,
id: String,
}
impl Scope {
pub fn global() -> Self {
Self::default()
}
pub fn of<M: Model>(model: &M) -> Self {
Self::of_id::<M>(model.id())
}
pub fn of_id<M: Model>(id: M::Key) -> Self {
Self::new(M::TABLE, id)
}
pub fn new(kind: &str, id: impl fmt::Display) -> Self {
Self {
kind: kind.to_owned(),
id: id.to_string(),
}
}
pub fn is_global(&self) -> bool {
self.kind.is_empty() && self.id.is_empty()
}
pub fn kind(&self) -> &str {
&self.kind
}
pub fn id(&self) -> &str {
&self.id
}
pub fn key<M: Model>(&self) -> Option<M::Key> {
(self.kind == M::TABLE)
.then(|| self.id.parse().ok())
.flatten()
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_global() {
f.write_str("global")
} else {
write!(f, "{}:{}", self.kind, self.id)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum Scopes<K> {
All,
Only(Vec<K>),
}
impl<K> Scopes<K> {
pub fn contains(&self, key: &K) -> bool
where
K: PartialEq,
{
match self {
Scopes::All => true,
Scopes::Only(keys) => keys.contains(key),
}
}
pub fn is_empty(&self) -> bool {
matches!(self, Scopes::Only(keys) if keys.is_empty())
}
pub fn apply<M: Model>(&self, query: Query<M>, columns: &[&str]) -> Query<M>
where
K: ToDbValue + Clone,
{
match self {
Scopes::All => query,
Scopes::Only(keys) if keys.is_empty() || columns.is_empty() => query.none(),
Scopes::Only(keys) => query.where_any(|mut group| {
for column in columns {
group = group.where_in(column, keys.iter().cloned());
}
group
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct Assignment {
pub role: String,
pub scope: Scope,
pub starts_at: Option<DateTime>,
pub ends_at: Option<DateTime>,
}
impl Assignment {
pub fn is_active(&self) -> bool {
self.is_active_at(now())
}
pub fn is_active_at(&self, at: DateTime) -> bool {
self.starts_at.is_none_or(|start| start <= at) && self.ends_at.is_none_or(|end| at < end)
}
}
#[derive(Clone)]
struct ActiveScope(Scope);
pub fn set_scope(scope: Scope) {
crate::context::set(ActiveScope(scope));
}
pub fn active_scope() -> Option<Scope> {
crate::context::get::<ActiveScope>().map(|active| active.0)
}
pub fn clear_scope() {
crate::context::remove::<ActiveScope>();
}
pub fn scopes_with<M: Model>(permission: &str) -> Scopes<M::Key> {
match super::current_user_id().and_then(super::current_grants) {
Some(grants) => grants.scopes_with::<M>(permission),
None => Scopes::Only(Vec::new()),
}
}
pub async fn define_role(db: &Db, name: &str, permissions: &[&str]) -> Result {
let mut tx = db.begin().await?;
let role = ensure(&mut tx, "roles", name).await?;
sql("DELETE FROM permission_role WHERE role_id = ?")
.bind(role)
.execute(&mut tx)
.await?;
for permission in permissions {
let permission = ensure(&mut tx, "permissions", permission).await?;
link(&mut tx, permission, role).await?;
}
tx.commit().await?;
Ok(())
}
pub async fn grant(db: &Db, role: &str, permissions: &[&str]) -> Result {
let mut tx = db.begin().await?;
let role = existing_role(&mut tx, role).await?;
for permission in permissions {
let permission = ensure(&mut tx, "permissions", permission).await?;
link(&mut tx, permission, role).await?;
}
tx.commit().await?;
Ok(())
}
pub async fn revoke(db: &Db, role: &str, permissions: &[&str]) -> Result {
let mut tx = db.begin().await?;
let role = existing_role(&mut tx, role).await?;
for permission in permissions {
sql(
"DELETE FROM permission_role WHERE role_id = ? AND permission_id = \
(SELECT id FROM permissions WHERE name = ?)",
)
.bind(role)
.bind(*permission)
.execute(&mut tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn delete_role(db: &Db, name: &str) -> Result<bool> {
Ok(sql("DELETE FROM roles WHERE name = ?")
.bind(name)
.execute(db)
.await?
> 0)
}
pub async fn roles(db: &Db) -> Result<Vec<(String, Vec<String>)>> {
let rows: Vec<(String, Option<String>)> = sql("SELECT r.name, p.name FROM roles r \
LEFT JOIN permission_role pr ON pr.role_id = r.id \
LEFT JOIN permissions p ON p.id = pr.permission_id \
ORDER BY r.name, p.name")
.fetch_as(db)
.await?;
let mut roles: Vec<(String, Vec<String>)> = Vec::new();
for (role, permission) in rows {
if roles.last().is_none_or(|(name, _)| *name != role) {
roles.push((role, Vec::new()));
}
if let (Some(permission), Some((_, list))) = (permission, roles.last_mut()) {
list.push(permission);
}
}
Ok(roles)
}
pub async fn prune_ended_assignments(db: &Db, age: std::time::Duration) -> Result<u64> {
let age = chrono::Duration::from_std(age).unwrap_or(chrono::Duration::MAX);
let cut_off = now()
.checked_sub_signed(age)
.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
Ok(
sql("DELETE FROM role_user WHERE ends_at IS NOT NULL AND ends_at < ?")
.bind(cut_off)
.execute(db)
.await?,
)
}
#[must_use = "an assignment does nothing until it is awaited"]
pub struct AssignRole<'a> {
db: &'a Db,
user_id: i64,
role: &'a str,
scope: Scope,
starts_at: Option<DateTime>,
ends_at: Option<DateTime>,
}
impl AssignRole<'_> {
pub fn from(mut self, at: DateTime) -> Self {
self.starts_at = Some(at);
self
}
pub fn until(mut self, at: DateTime) -> Self {
self.ends_at = Some(at);
self
}
}
impl<'a> IntoFuture for AssignRole<'a> {
type Output = Result;
type IntoFuture = Pin<Box<dyn Future<Output = Result> + Send + 'a>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
if let (Some(start), Some(end)) = (self.starts_at, self.ends_at)
&& end <= start
{
return Err(
anyhow::anyhow!("the role `{}` would end before it starts", self.role).into(),
);
}
assign(
self.db,
self.user_id,
self.role,
&self.scope,
self.starts_at,
self.ends_at,
)
.await
})
}
}
async fn assign(
db: &Db,
user_id: i64,
role: &str,
scope: &Scope,
starts_at: Option<DateTime>,
ends_at: Option<DateTime>,
) -> Result {
let mut tx = db.begin().await?;
let role = existing_role(&mut tx, role).await?;
sql(
"INSERT INTO role_user (role_id, user_id, scope_type, scope_id, starts_at, ends_at) \
VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT (role_id, user_id, scope_type, scope_id) \
DO UPDATE SET starts_at = excluded.starts_at, ends_at = excluded.ends_at",
)
.bind(role)
.bind(user_id)
.bind(scope.kind())
.bind(scope.id())
.bind(starts_at)
.bind(ends_at)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(())
}
impl User {
pub async fn assign_role(&self, db: &Db, role: &str) -> Result {
assign(db, self.id, role, &Scope::global(), None, None).await
}
pub fn assign_role_in<'a>(&self, db: &'a Db, role: &'a str, scope: &Scope) -> AssignRole<'a> {
AssignRole {
db,
user_id: self.id,
role,
scope: scope.clone(),
starts_at: None,
ends_at: None,
}
}
pub async fn remove_role(&self, db: &Db, role: &str) -> Result {
self.remove_role_in(db, role, &Scope::global()).await
}
pub async fn remove_role_in(&self, db: &Db, role: &str, scope: &Scope) -> Result {
sql(
"DELETE FROM role_user WHERE user_id = ? AND scope_type = ? AND scope_id = ? \
AND role_id = (SELECT id FROM roles WHERE name = ?)",
)
.bind(self.id)
.bind(scope.kind())
.bind(scope.id())
.bind(role)
.execute(db)
.await?;
Ok(())
}
pub async fn sync_roles(&self, db: &Db, roles: &[&str]) -> Result {
self.sync_roles_in(db, roles, &Scope::global()).await
}
pub async fn sync_roles_in(&self, db: &Db, roles: &[&str], scope: &Scope) -> Result {
let mut tx = db.begin().await?;
let mut ids = Vec::new();
for role in roles {
ids.push(existing_role(&mut tx, role).await?);
}
sql("DELETE FROM role_user WHERE user_id = ? AND scope_type = ? AND scope_id = ?")
.bind(self.id)
.bind(scope.kind())
.bind(scope.id())
.execute(&mut tx)
.await?;
for role in ids {
sql(
"INSERT INTO role_user (role_id, user_id, scope_type, scope_id) \
VALUES (?, ?, ?, ?)",
)
.bind(role)
.bind(self.id)
.bind(scope.kind())
.bind(scope.id())
.execute(&mut tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn roles(&self, db: &Db) -> Result<Vec<String>> {
Ok(grants(db, self.id).await?.roles())
}
pub async fn permissions(&self, db: &Db) -> Result<Vec<String>> {
let grants = grants(db, self.id).await?;
let scope = active_scope();
let mut list: Vec<String> = grants
.permissions_in(scope.as_ref())
.into_iter()
.map(str::to_owned)
.collect();
list.sort();
Ok(list)
}
pub async fn assignments(&self, db: &Db) -> Result<Vec<Assignment>> {
Ok(load_assignments(db, self.id, false)
.await?
.into_iter()
.map(|granted| Assignment {
role: granted.role,
scope: granted.scope,
starts_at: granted.starts_at,
ends_at: granted.ends_at,
})
.collect())
}
pub fn has_role_in(&self, role: &str, scope: &Scope) -> bool {
super::current_grants(self.id).is_some_and(|g| g.has_role_in(role, Some(scope)))
}
pub fn has_permission_in(&self, permission: &str, scope: &Scope) -> bool {
super::current_grants(self.id).is_some_and(|g| g.has_permission_in(permission, Some(scope)))
}
pub fn scopes_with<M: Model>(&self, permission: &str) -> Scopes<M::Key> {
match super::current_grants(self.id) {
Some(grants) => grants.scopes_with::<M>(permission),
None => Scopes::Only(Vec::new()),
}
}
}
pub async fn users_with_role(db: &Db, role: &str) -> Result<Vec<User>> {
users_with_role_in(db, role, &Scope::global()).await
}
pub async fn users_with_role_in(db: &Db, role: &str, scope: &Scope) -> Result<Vec<User>> {
let at = now();
let ids: Vec<i64> = sql("SELECT DISTINCT ru.user_id FROM role_user ru \
JOIN roles r ON r.id = ru.role_id \
WHERE r.name = ? \
AND ((ru.scope_type = '' AND ru.scope_id = '') OR (ru.scope_type = ? AND ru.scope_id = ?)) \
AND (ru.starts_at IS NULL OR ru.starts_at <= ?) \
AND (ru.ends_at IS NULL OR ru.ends_at > ?) \
ORDER BY ru.user_id")
.bind(role)
.bind(scope.kind())
.bind(scope.id())
.bind(at)
.bind(at)
.scalars(db)
.await?;
let mut users = User::find_many(db, ids).await?;
users.sort_by_key(|u| u.id);
Ok(users)
}
#[derive(Debug, Clone)]
pub(crate) struct Granted {
role: String,
scope: Scope,
starts_at: Option<DateTime>,
ends_at: Option<DateTime>,
}
impl Granted {
fn active_at(&self, at: DateTime) -> bool {
self.starts_at.is_none_or(|start| start <= at) && self.ends_at.is_none_or(|end| at < end)
}
}
#[derive(Default, Debug)]
pub(crate) struct Grants {
assignments: Vec<Granted>,
permissions: HashMap<String, HashSet<String>>,
}
impl Grants {
fn in_effect<'a>(&'a self, scope: Option<&'a Scope>) -> impl Iterator<Item = &'a Granted> {
let at = now();
self.assignments.iter().filter(move |granted| {
(granted.scope.is_global() || scope.is_some_and(|scope| granted.scope == *scope))
&& granted.active_at(at)
})
}
fn grants(&self, granted: &Granted, permission: &str) -> bool {
self.permissions
.get(&granted.role)
.is_some_and(|list| list.contains(permission))
}
pub(crate) fn roles(&self) -> Vec<String> {
let scope = active_scope();
let mut roles: Vec<String> = self
.in_effect(scope.as_ref())
.map(|granted| granted.role.clone())
.collect();
roles.sort();
roles.dedup();
roles
}
pub(crate) fn has_role(&self, role: &str) -> bool {
self.has_role_in(role, active_scope().as_ref())
}
pub(crate) fn has_permission(&self, permission: &str) -> bool {
self.has_permission_in(permission, active_scope().as_ref())
}
pub(crate) fn has_role_in(&self, role: &str, scope: Option<&Scope>) -> bool {
self.in_effect(scope).any(|granted| granted.role == role)
}
pub(crate) fn has_permission_in(&self, permission: &str, scope: Option<&Scope>) -> bool {
self.in_effect(scope)
.any(|granted| self.grants(granted, permission))
}
fn permissions_in(&self, scope: Option<&Scope>) -> HashSet<&str> {
self.in_effect(scope)
.filter_map(|granted| self.permissions.get(&granted.role))
.flatten()
.map(String::as_str)
.collect()
}
pub(crate) fn scopes_with<M: Model>(&self, permission: &str) -> Scopes<M::Key> {
let at = now();
let mut keys: Vec<M::Key> = Vec::new();
for granted in &self.assignments {
if !granted.active_at(at) || !self.grants(granted, permission) {
continue;
}
if granted.scope.is_global() {
return Scopes::All;
}
if let Some(key) = granted.scope.key::<M>()
&& !keys.contains(&key)
{
keys.push(key);
}
}
keys.sort();
Scopes::Only(keys)
}
}
async fn load_assignments(db: &Db, user_id: i64, current: bool) -> Result<Vec<Granted>> {
let mut query = String::from(
"SELECT r.name, ru.scope_type, ru.scope_id, ru.starts_at, ru.ends_at \
FROM role_user ru JOIN roles r ON r.id = ru.role_id WHERE ru.user_id = ?",
);
if current {
query.push_str(" AND (ru.ends_at IS NULL OR ru.ends_at > ?)");
}
query.push_str(" ORDER BY r.name, ru.scope_type, ru.scope_id");
let mut statement = sql(query).bind(user_id);
if current {
statement = statement.bind(now());
}
type Row = (String, String, String, Option<DateTime>, Option<DateTime>);
let rows: Vec<Row> = statement.fetch_as(db).await?;
Ok(rows
.into_iter()
.map(|(role, kind, id, starts_at, ends_at)| Granted {
role,
scope: Scope { kind, id },
starts_at,
ends_at,
})
.collect())
}
pub(crate) async fn grants(db: &Db, user_id: i64) -> Result<Grants> {
let assignments = load_assignments(db, user_id, true).await?;
let mut permissions: HashMap<String, HashSet<String>> = HashMap::new();
if !assignments.is_empty() {
let rows: Vec<(String, String)> = sql("SELECT DISTINCT r.name, p.name FROM roles r \
JOIN permission_role pr ON pr.role_id = r.id \
JOIN permissions p ON p.id = pr.permission_id \
WHERE r.id IN (SELECT role_id FROM role_user WHERE user_id = ?)")
.bind(user_id)
.fetch_as(db)
.await?;
for (role, permission) in rows {
permissions.entry(role).or_default().insert(permission);
}
}
Ok(Grants {
assignments,
permissions,
})
}
async fn ensure(tx: &mut crate::db::Transaction, table: &str, name: &str) -> Result<i64> {
let at = now();
sql(format!(
"INSERT INTO {table} (name, created_at, updated_at) SELECT ?, ?, ? \
WHERE NOT EXISTS (SELECT 1 FROM {table} WHERE name = ?)"
))
.bind(name)
.bind(at)
.bind(at)
.bind(name)
.execute(&mut *tx)
.await?;
Ok(sql(format!("SELECT id FROM {table} WHERE name = ?"))
.bind(name)
.scalar(&mut *tx)
.await?)
}
async fn existing_role(tx: &mut crate::db::Transaction, name: &str) -> Result<i64> {
let id: Option<i64> = sql("SELECT id FROM roles WHERE name = ?")
.bind(name)
.scalars(&mut *tx)
.await?
.into_iter()
.next();
id.ok_or_else(|| {
anyhow::anyhow!("there is no role `{name}`; create it with permissions::define_role").into()
})
}
async fn link(tx: &mut crate::db::Transaction, permission: i64, role: i64) -> Result {
sql(
"INSERT INTO permission_role (permission_id, role_id) SELECT ?, ? \
WHERE NOT EXISTS (SELECT 1 FROM permission_role WHERE permission_id = ? AND role_id = ?)",
)
.bind(permission)
.bind(role)
.bind(permission)
.bind(role)
.execute(&mut *tx)
.await?;
Ok(())
}