use crate::db::Db;
use crate::error::AuthError;
use crate::types::{Role, RoleId, RoleName, UserId};
fn map_unique_violation(err: sqlx::Error) -> AuthError {
if let sqlx::Error::Database(ref db_err) = err {
let msg = db_err.message();
if msg.contains("UNIQUE constraint failed") && msg.contains("name") {
return AuthError::Conflict("role name already exists".into());
}
}
AuthError::Database(err)
}
impl Db {
pub async fn create_role(
&self,
name: &RoleName,
description: Option<&str>,
) -> Result<Role, AuthError> {
let id = RoleId::new();
sqlx::query_as::<_, Role>(
"INSERT INTO allowthem_roles (id, name, description) \
VALUES (?, ?, ?) \
RETURNING id, name, description, created_at",
)
.bind(id)
.bind(name)
.bind(description)
.fetch_one(self.pool())
.await
.map_err(map_unique_violation)
}
pub async fn get_role(&self, id: &RoleId) -> Result<Option<Role>, AuthError> {
sqlx::query_as::<_, Role>(
"SELECT id, name, description, created_at FROM allowthem_roles WHERE id = ?",
)
.bind(*id)
.fetch_optional(self.pool())
.await
.map_err(AuthError::Database)
}
pub async fn get_role_by_name(&self, name: &RoleName) -> Result<Option<Role>, AuthError> {
sqlx::query_as::<_, Role>(
"SELECT id, name, description, created_at FROM allowthem_roles WHERE name = ?",
)
.bind(name)
.fetch_optional(self.pool())
.await
.map_err(AuthError::Database)
}
pub async fn list_roles(&self) -> Result<Vec<Role>, AuthError> {
sqlx::query_as::<_, Role>(
"SELECT id, name, description, created_at FROM allowthem_roles ORDER BY created_at",
)
.fetch_all(self.pool())
.await
.map_err(AuthError::Database)
}
pub async fn delete_role(&self, id: &RoleId) -> Result<bool, AuthError> {
let result = sqlx::query("DELETE FROM allowthem_roles WHERE id = ?")
.bind(*id)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(result.rows_affected() > 0)
}
pub async fn assign_role(&self, user_id: &UserId, role_id: &RoleId) -> Result<(), AuthError> {
sqlx::query("INSERT OR IGNORE INTO allowthem_user_roles (user_id, role_id) VALUES (?, ?)")
.bind(*user_id)
.bind(*role_id)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(())
}
pub async fn unassign_role(
&self,
user_id: &UserId,
role_id: &RoleId,
) -> Result<bool, AuthError> {
let result =
sqlx::query("DELETE FROM allowthem_user_roles WHERE user_id = ? AND role_id = ?")
.bind(*user_id)
.bind(*role_id)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(result.rows_affected() > 0)
}
pub async fn has_role(
&self,
user_id: &UserId,
role_name: &RoleName,
) -> Result<bool, AuthError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) \
FROM allowthem_user_roles ur \
JOIN allowthem_roles r ON r.id = ur.role_id \
WHERE ur.user_id = ? AND r.name = ?",
)
.bind(*user_id)
.bind(role_name)
.fetch_one(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(count > 0)
}
pub async fn get_user_roles(&self, user_id: &UserId) -> Result<Vec<Role>, AuthError> {
sqlx::query_as::<_, Role>(
"SELECT r.id, r.name, r.description, r.created_at \
FROM allowthem_roles r \
JOIN allowthem_user_roles ur ON ur.role_id = r.id \
WHERE ur.user_id = ? \
ORDER BY r.created_at",
)
.bind(*user_id)
.fetch_all(self.pool())
.await
.map_err(AuthError::Database)
}
pub async fn bootstrap_roles(&self, names: &[&str]) -> Result<Vec<Role>, AuthError> {
let mut roles = Vec::with_capacity(names.len());
for &name in names {
let rn = RoleName::new(name);
let role = match self.get_role_by_name(&rn).await? {
Some(r) => r,
None => self.create_role(&rn, None).await?,
};
roles.push(role);
}
Ok(roles)
}
pub async fn resolve_highest_role(
&self,
user_id: &UserId,
hierarchy: &[&str],
) -> Result<Option<String>, AuthError> {
for &name in hierarchy {
let rn = RoleName::new(name);
if self.has_role(user_id, &rn).await? {
return Ok(Some(name.to_owned()));
}
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::{AllowThem, AllowThemBuilder};
use crate::types::Email;
async fn setup() -> AllowThem {
AllowThemBuilder::new("sqlite::memory:")
.cookie_secure(false)
.build()
.await
.unwrap()
}
#[tokio::test]
async fn bootstrap_roles_creates_missing_roles() {
let ath = setup().await;
let db = ath.db();
let roles = db.bootstrap_roles(&["admin", "editor"]).await.unwrap();
assert_eq!(roles.len(), 2);
assert_eq!(roles[0].name.as_str(), "admin");
assert_eq!(roles[1].name.as_str(), "editor");
}
#[tokio::test]
async fn bootstrap_roles_idempotent() {
let ath = setup().await;
let db = ath.db();
let first = db.bootstrap_roles(&["admin", "editor"]).await.unwrap();
let second = db.bootstrap_roles(&["admin", "editor"]).await.unwrap();
assert_eq!(first[0].id, second[0].id);
assert_eq!(first[1].id, second[1].id);
}
#[tokio::test]
async fn bootstrap_roles_returns_in_input_order() {
let ath = setup().await;
let db = ath.db();
let roles = db
.bootstrap_roles(&["viewer", "admin", "editor"])
.await
.unwrap();
assert_eq!(roles[0].name.as_str(), "viewer");
assert_eq!(roles[1].name.as_str(), "admin");
assert_eq!(roles[2].name.as_str(), "editor");
}
#[tokio::test]
async fn bootstrap_roles_mixed_existing_and_new() {
let ath = setup().await;
let db = ath.db();
let rn = RoleName::new("admin");
db.create_role(&rn, None).await.unwrap();
let roles = db.bootstrap_roles(&["admin", "viewer"]).await.unwrap();
assert_eq!(roles.len(), 2);
assert_eq!(roles[0].name.as_str(), "admin");
assert_eq!(roles[1].name.as_str(), "viewer");
}
#[tokio::test]
async fn bootstrap_roles_empty_slice_returns_empty_vec() {
let ath = setup().await;
let db = ath.db();
let roles = db.bootstrap_roles(&[]).await.unwrap();
assert!(roles.is_empty());
}
#[tokio::test]
async fn resolve_highest_role_returns_first_match() {
let ath = setup().await;
let db = ath.db();
let email = Email::new("user@example.com".into()).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
let roles = db
.bootstrap_roles(&["admin", "editor", "viewer"])
.await
.unwrap();
db.assign_role(&user.id, &roles[1].id).await.unwrap(); db.assign_role(&user.id, &roles[2].id).await.unwrap(); let result = db
.resolve_highest_role(&user.id, &["admin", "editor", "viewer"])
.await
.unwrap();
assert_eq!(result, Some("editor".to_owned()));
}
#[tokio::test]
async fn resolve_highest_role_returns_none_when_no_roles() {
let ath = setup().await;
let db = ath.db();
let email = Email::new("noroles@example.com".into()).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
let result = db
.resolve_highest_role(&user.id, &["admin", "editor"])
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_highest_role_returns_none_for_empty_hierarchy() {
let ath = setup().await;
let db = ath.db();
let email = Email::new("emptyhier@example.com".into()).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
let result = db.resolve_highest_role(&user.id, &[]).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_highest_role_returns_highest_when_user_has_all() {
let ath = setup().await;
let db = ath.db();
let email = Email::new("allroles@example.com".into()).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
let roles = db
.bootstrap_roles(&["admin", "editor", "viewer"])
.await
.unwrap();
for role in &roles {
db.assign_role(&user.id, &role.id).await.unwrap();
}
let result = db
.resolve_highest_role(&user.id, &["admin", "editor", "viewer"])
.await
.unwrap();
assert_eq!(result, Some("admin".to_owned()));
}
#[tokio::test]
async fn resolve_highest_role_only_considers_listed_roles() {
let ath = setup().await;
let db = ath.db();
let email = Email::new("unlisted@example.com".into()).unwrap();
let user = db
.create_user(email, "password123", None, None)
.await
.unwrap();
let rn = RoleName::new("superuser");
let role = db.create_role(&rn, None).await.unwrap();
db.assign_role(&user.id, &role.id).await.unwrap();
let result = db
.resolve_highest_role(&user.id, &["admin", "editor"])
.await
.unwrap();
assert!(result.is_none());
}
}