use crate::core::{DeleteQuery, Filter, Op, SqlValue, WhereExpr};
use crate::core::Model as _;
use crate::sql::sqlx::{self, PgPool, Row};
use crate::sql::Auto;
use crate::Model;
use super::error::TenancyError;
#[derive(Model, Debug, Clone)]
#[rustango(
table = "rustango_roles",
display = "name",
admin(
list_display = "name, description",
search_fields = "name, description",
ordering = "name",
),
)]
pub struct Role {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 150, unique)]
pub name: String,
#[rustango(max_length = 500)]
pub description: String,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
#[derive(Model, Debug, Clone)]
#[rustango(table = "rustango_role_permissions")]
pub struct RolePermission {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub role_id: i64,
#[rustango(max_length = 100)]
pub codename: String,
}
#[derive(Model, Debug, Clone)]
#[rustango(table = "rustango_user_roles")]
pub struct UserRole {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
pub role_id: i64,
}
#[derive(Model, Debug, Clone)]
#[rustango(table = "rustango_user_permissions")]
pub struct UserPermission {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
#[rustango(max_length = 100)]
pub codename: String,
pub granted: bool,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
const ENSURE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_permissions" (
"id" BIGSERIAL PRIMARY KEY,
"table_name" VARCHAR(150) NOT NULL,
"codename" VARCHAR(100) NOT NULL,
"name" VARCHAR(255) NOT NULL DEFAULT '',
CONSTRAINT "rustango_permissions_uq" UNIQUE ("table_name", "codename")
);
CREATE TABLE IF NOT EXISTS "rustango_roles" (
"id" BIGSERIAL PRIMARY KEY,
"name" VARCHAR(150) NOT NULL,
"description" VARCHAR(500) NOT NULL DEFAULT '',
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_roles_name_uq" UNIQUE ("name")
);
ALTER TABLE "rustango_roles"
ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
CREATE TABLE IF NOT EXISTS "rustango_role_permissions" (
"id" BIGSERIAL PRIMARY KEY,
"role_id" BIGINT NOT NULL
REFERENCES "rustango_roles"("id")
ON DELETE CASCADE,
"codename" VARCHAR(100) NOT NULL,
CONSTRAINT "rustango_role_permissions_uq" UNIQUE ("role_id", "codename")
);
CREATE TABLE IF NOT EXISTS "rustango_user_roles" (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL
REFERENCES "rustango_users"("id")
ON DELETE CASCADE,
"role_id" BIGINT NOT NULL
REFERENCES "rustango_roles"("id")
ON DELETE CASCADE,
CONSTRAINT "rustango_user_roles_uq" UNIQUE ("user_id", "role_id")
);
CREATE TABLE IF NOT EXISTS "rustango_user_permissions" (
"id" BIGSERIAL PRIMARY KEY,
"user_id" BIGINT NOT NULL
REFERENCES "rustango_users"("id")
ON DELETE CASCADE,
"codename" VARCHAR(100) NOT NULL,
"granted" BOOLEAN NOT NULL DEFAULT TRUE,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_user_permissions_uq" UNIQUE ("user_id", "codename")
);
ALTER TABLE "rustango_user_permissions"
ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "rustango_users"
ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
"#;
pub async fn ensure_tables(pool: &PgPool) -> Result<(), sqlx::Error> {
for stmt in ENSURE_SQL.split(';').map(str::trim).filter(|s| !s.is_empty()) {
sqlx::query(stmt).execute(pool).await?;
}
Ok(())
}
pub async fn has_perm(uid: i64, codename: &str, pool: &PgPool) -> Result<bool, sqlx::Error> {
let row = sqlx::query(
r#"
WITH user_info AS (
SELECT is_superuser
FROM "rustango_users"
WHERE id = $1 AND active = TRUE
),
explicit AS (
SELECT granted
FROM "rustango_user_permissions"
WHERE user_id = $1 AND codename = $2
),
via_role AS (
SELECT 1
FROM "rustango_user_roles" ur
JOIN "rustango_role_permissions" rp
ON rp.role_id = ur.role_id
WHERE ur.user_id = $1 AND rp.codename = $2
LIMIT 1
)
SELECT
COALESCE((SELECT is_superuser FROM user_info), FALSE) AS is_super,
(SELECT granted FROM explicit) AS explicit_grant,
EXISTS(SELECT 1 FROM via_role) AS via_role
"#,
)
.bind(uid)
.bind(codename)
.fetch_one(pool)
.await?;
let is_super: bool = row.try_get("is_super").unwrap_or(false);
if is_super {
return Ok(true);
}
let explicit: Option<bool> = row.try_get("explicit_grant").unwrap_or(None);
if let Some(granted) = explicit {
return Ok(granted);
}
Ok(row.try_get("via_role").unwrap_or(false))
}
pub async fn has_any_perm(
uid: i64,
codenames: &[&str],
pool: &PgPool,
) -> Result<bool, sqlx::Error> {
if codenames.is_empty() {
return Ok(false);
}
let names: Vec<&str> = codenames.to_vec();
let row = sqlx::query(
r#"
WITH user_info AS (
SELECT is_superuser
FROM "rustango_users"
WHERE id = $1 AND active = TRUE
),
denied AS (
SELECT codename
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = FALSE AND codename = ANY($2::text[])
),
via_role AS (
SELECT 1
FROM "rustango_user_roles" ur
JOIN "rustango_role_permissions" rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1
AND rp.codename = ANY($2::text[])
AND rp.codename NOT IN (SELECT codename FROM denied)
LIMIT 1
),
explicit_grant AS (
SELECT 1
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = TRUE
AND codename = ANY($2::text[])
AND codename NOT IN (SELECT codename FROM denied)
LIMIT 1
)
SELECT
COALESCE((SELECT is_superuser FROM user_info), FALSE) AS is_super,
EXISTS(SELECT 1 FROM via_role) OR EXISTS(SELECT 1 FROM explicit_grant) AS has_any
"#,
)
.bind(uid)
.bind(names)
.fetch_one(pool)
.await?;
let is_super: bool = row.try_get("is_super").unwrap_or(false);
if is_super {
return Ok(true);
}
Ok(row.try_get("has_any").unwrap_or(false))
}
pub async fn has_all_perms(
uid: i64,
codenames: &[&str],
pool: &PgPool,
) -> Result<bool, sqlx::Error> {
if codenames.is_empty() {
return Ok(true);
}
let names: Vec<&str> = codenames.to_vec();
let expected = codenames.len() as i64;
let row = sqlx::query(
r#"
WITH user_info AS (
SELECT is_superuser
FROM "rustango_users"
WHERE id = $1 AND active = TRUE
),
denied AS (
SELECT codename
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = FALSE AND codename = ANY($2::text[])
),
effective AS (
SELECT rp.codename
FROM "rustango_user_roles" ur
JOIN "rustango_role_permissions" rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1
AND rp.codename = ANY($2::text[])
AND rp.codename NOT IN (SELECT codename FROM denied)
UNION
SELECT codename
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = TRUE
AND codename = ANY($2::text[])
AND codename NOT IN (SELECT codename FROM denied)
)
SELECT
COALESCE((SELECT is_superuser FROM user_info), FALSE) AS is_super,
COUNT(DISTINCT codename) AS matched
FROM effective
"#,
)
.bind(uid)
.bind(names)
.fetch_one(pool)
.await?;
let is_super: bool = row.try_get("is_super").unwrap_or(false);
if is_super {
return Ok(true);
}
let matched: i64 = row.try_get("matched").unwrap_or(0);
Ok(matched == expected)
}
pub async fn create_role(
name: &str,
description: &str,
pool: &PgPool,
) -> Result<i64, TenancyError> {
let mut role = Role {
id: Auto::default(),
name: name.to_owned(),
description: description.to_owned(),
data: serde_json::Value::Object(serde_json::Map::new()),
};
role.save_on(pool).await?;
Ok(role.id.get().copied().unwrap_or(0))
}
pub async fn get_or_create_role(
name: &str,
description: &str,
pool: &PgPool,
) -> Result<i64, TenancyError> {
let row = sqlx::query(
r#"
WITH ins AS (
INSERT INTO "rustango_roles" (name, description, data)
VALUES ($1, $2, '{}')
ON CONFLICT (name) DO NOTHING
RETURNING id
)
SELECT id FROM ins
UNION ALL
SELECT id FROM "rustango_roles" WHERE name = $1
LIMIT 1
"#,
)
.bind(name)
.bind(description)
.fetch_one(pool)
.await?;
Ok(row.try_get::<i64, _>("id").unwrap_or(0))
}
pub async fn grant_role_perm(
role_id: i64,
codename: &str,
pool: &PgPool,
) -> Result<(), TenancyError> {
sqlx::query(
r#"INSERT INTO "rustango_role_permissions" (role_id, codename)
VALUES ($1, $2)
ON CONFLICT (role_id, codename) DO NOTHING"#,
)
.bind(role_id)
.bind(codename)
.execute(pool)
.await?;
Ok(())
}
pub async fn revoke_role_perm(
role_id: i64,
codename: &str,
pool: &PgPool,
) -> Result<(), TenancyError> {
crate::sql::delete(pool, &DeleteQuery {
model: RolePermission::SCHEMA,
where_clause: WhereExpr::and_predicates(vec![
Filter { column: "role_id", op: Op::Eq, value: SqlValue::from(role_id) },
Filter { column: "codename", op: Op::Eq, value: SqlValue::from(codename) },
]),
})
.await?;
Ok(())
}
pub async fn assign_role(
user_id: i64,
role_id: i64,
pool: &PgPool,
) -> Result<(), TenancyError> {
sqlx::query(
r#"INSERT INTO "rustango_user_roles" (user_id, role_id)
VALUES ($1, $2)
ON CONFLICT (user_id, role_id) DO NOTHING"#,
)
.bind(user_id)
.bind(role_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn remove_role(
user_id: i64,
role_id: i64,
pool: &PgPool,
) -> Result<(), TenancyError> {
crate::sql::delete(pool, &DeleteQuery {
model: UserRole::SCHEMA,
where_clause: WhereExpr::and_predicates(vec![
Filter { column: "user_id", op: Op::Eq, value: SqlValue::from(user_id) },
Filter { column: "role_id", op: Op::Eq, value: SqlValue::from(role_id) },
]),
})
.await?;
Ok(())
}
pub async fn set_user_perm(
user_id: i64,
codename: &str,
granted: bool,
pool: &PgPool,
) -> Result<(), TenancyError> {
sqlx::query(
r#"INSERT INTO "rustango_user_permissions" (user_id, codename, granted, data)
VALUES ($1, $2, $3, '{}')
ON CONFLICT (user_id, codename) DO UPDATE SET granted = EXCLUDED.granted"#,
)
.bind(user_id)
.bind(codename)
.bind(granted)
.execute(pool)
.await?;
Ok(())
}
pub async fn clear_user_perm(
user_id: i64,
codename: &str,
pool: &PgPool,
) -> Result<(), TenancyError> {
crate::sql::delete(pool, &DeleteQuery {
model: UserPermission::SCHEMA,
where_clause: WhereExpr::and_predicates(vec![
Filter { column: "user_id", op: Op::Eq, value: SqlValue::from(user_id) },
Filter { column: "codename", op: Op::Eq, value: SqlValue::from(codename) },
]),
})
.await?;
Ok(())
}
pub async fn user_roles_qs(user_id: i64, pool: &PgPool) -> Result<Vec<Role>, sqlx::Error> {
let rows = sqlx::query(
r#"SELECT r.id, r.name, r.description
FROM "rustango_roles" r
JOIN "rustango_user_roles" ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.name"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
rows.iter()
.map(|row| {
Ok(Role {
id: Auto::Set(row.try_get::<i64, _>("id")?),
name: row.try_get("name")?,
description: row.try_get("description")?,
data: row.try_get::<serde_json::Value, _>("data").unwrap_or_else(|_| serde_json::json!({})),
})
})
.collect()
}
pub async fn user_roles(uid: i64, pool: &PgPool) -> Result<Vec<(i64, String)>, sqlx::Error> {
let roles = user_roles_qs(uid, pool).await?; Ok(roles
.into_iter()
.map(|r| (r.id.get().copied().unwrap_or(0), r.name))
.collect())
}
pub async fn user_permissions(uid: i64, pool: &PgPool) -> Result<Vec<String>, TenancyError> {
let rows = sqlx::query(
r#"
WITH denied AS (
SELECT codename
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = FALSE
)
SELECT DISTINCT codename
FROM (
SELECT rp.codename
FROM "rustango_user_roles" ur
JOIN "rustango_role_permissions" rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1
AND rp.codename NOT IN (SELECT codename FROM denied)
UNION ALL
SELECT codename
FROM "rustango_user_permissions"
WHERE user_id = $1 AND granted = TRUE
AND codename NOT IN (SELECT codename FROM denied)
) effective
ORDER BY codename
"#,
)
.bind(uid)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| r.try_get::<String, _>("codename").unwrap_or_default())
.collect())
}
#[must_use]
pub fn model_codenames(table: &str) -> [String; 4] {
[
format!("{table}.add"),
format!("{table}.change"),
format!("{table}.delete"),
format!("{table}.view"),
]
}
pub async fn auto_create_permissions(pool: &PgPool) -> Result<(), sqlx::Error> {
use crate::core::{inventory, ModelEntry};
let action_names = [
("add", "Can add"),
("change", "Can change"),
("delete", "Can delete"),
("view", "Can view"),
];
let mut tables: Vec<&str> = Vec::new();
let mut codenames: Vec<String> = Vec::new();
let mut names: Vec<String> = Vec::new();
for entry in inventory::iter::<ModelEntry> {
if !entry.schema.permissions {
continue;
}
let table = entry.schema.table;
let model_name = entry.schema.name;
for (action, verb) in &action_names {
tables.push(table);
codenames.push(format!("{table}.{action}"));
names.push(format!("{verb} {model_name}"));
}
}
if tables.is_empty() {
return Ok(());
}
sqlx::query(
r#"INSERT INTO "rustango_permissions" (table_name, codename, name)
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[])
ON CONFLICT (table_name, codename) DO NOTHING"#,
)
.bind(&tables)
.bind(&codenames)
.bind(&names)
.execute(pool)
.await?;
Ok(())
}