Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
-- 1 = active, 2 = disabled, 3 = locked
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE (tenant_id, username)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
metadata_json TEXT
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON service_accounts(tenant_id);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_tenant ON applications(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_slug ON applications(slug);
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
target_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
-- 'info', 'warning', 'critical'
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_severity ON audit_logs(severity);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Seed the default tenant.
|
||||
-- Uses INSERT OR IGNORE so re-running migrations is safe.
|
||||
INSERT OR IGNORE INTO tenants (id, name, slug, enabled)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ── Roles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO roles (id, name, description) VALUES
|
||||
('10000000-0000-0000-0000-000000000001', 'admin', 'Full system access'),
|
||||
('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'),
|
||||
('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access');
|
||||
|
||||
-- ── Permissions ───────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
|
||||
('20000000-0000-0000-0000-000000000001', 'users:create', 'Create new user accounts'),
|
||||
('20000000-0000-0000-0000-000000000002', 'users:update', 'Update user accounts'),
|
||||
('20000000-0000-0000-0000-000000000003', 'users:delete', 'Disable user accounts'),
|
||||
('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'),
|
||||
('20000000-0000-0000-0000-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'),
|
||||
('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'),
|
||||
('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries');
|
||||
|
||||
-- ── Admin role gets all permissions ──────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
|
||||
|
||||
-- ── Editor role permissions ───────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001'),
|
||||
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002');
|
||||
|
||||
-- ── Default applications ──────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES
|
||||
('30000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'BZOD', 'bzod', 1),
|
||||
('30000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'ChronoSeal', 'chronoseal', 1),
|
||||
('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1);
|
||||
@@ -0,0 +1,68 @@
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
|
||||
/// Create and configure the SQLite connection pool.
|
||||
///
|
||||
/// Enables WAL mode, foreign keys, and a busy timeout so concurrent writers
|
||||
/// do not immediately error — they back off and retry for up to 5 seconds.
|
||||
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create database directory: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("sqlite://{}?mode=rwc", path);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(16)
|
||||
.min_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.with_context(|| format!("failed to open database: {path}"))?;
|
||||
|
||||
// Apply foundational PRAGMAs on every connection
|
||||
sqlx::query("PRAGMA journal_mode = WAL")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.context("PRAGMA journal_mode")?;
|
||||
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.context("PRAGMA foreign_keys")?;
|
||||
|
||||
sqlx::query("PRAGMA busy_timeout = 5000")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.context("PRAGMA busy_timeout")?;
|
||||
|
||||
sqlx::query("PRAGMA synchronous = NORMAL")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.context("PRAGMA synchronous")?;
|
||||
|
||||
sqlx::query("PRAGMA cache_size = -32768") // 32 MiB page cache
|
||||
.execute(&pool)
|
||||
.await
|
||||
.context("PRAGMA cache_size")?;
|
||||
|
||||
tracing::info!(path = path, "database pool opened");
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Run all pending SQLx migrations embedded in `src/db/migrations/`.
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::migrate!("src/db/migrations")
|
||||
.run(pool)
|
||||
.await
|
||||
.context("failed to run database migrations")?;
|
||||
tracing::info!("database migrations applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub mod models;
|
||||
pub mod repository;
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// A personal access token row from the `api_tokens` table.
|
||||
///
|
||||
/// `token_hash` is the BLAKE3 hex-encoded hash of the raw `nx9_pat_...` token.
|
||||
/// The raw token is displayed exactly once at creation time and never stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ApiToken {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
/// BLAKE3 hex hash — never expose in API responses.
|
||||
#[serde(skip_serializing)]
|
||||
pub token_hash: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Application {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Audit event severity level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuditSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl AuditSeverity {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Critical => "critical",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AuditSeverity {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"warning" => Ok(Self::Warning),
|
||||
"critical" => Ok(Self::Critical),
|
||||
_ => Ok(Self::Info),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuditSeverity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A row from the `audit_logs` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct AuditLog {
|
||||
pub id: String,
|
||||
pub actor_user_id: Option<String>,
|
||||
pub target_user_id: Option<String>,
|
||||
pub action: String,
|
||||
pub resource_type: String,
|
||||
pub resource_id: Option<String>,
|
||||
pub severity: String,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub metadata_json: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod api_token;
|
||||
pub mod application;
|
||||
pub mod audit_log;
|
||||
pub mod permission;
|
||||
pub mod role;
|
||||
pub mod service_account;
|
||||
pub mod session;
|
||||
pub mod tenant;
|
||||
pub mod user;
|
||||
|
||||
pub use api_token::ApiToken;
|
||||
pub use application::Application;
|
||||
pub use audit_log::{AuditLog, AuditSeverity};
|
||||
#[allow(unused_imports)]
|
||||
pub use permission::Permission;
|
||||
pub use role::Role;
|
||||
pub use service_account::ServiceAccount;
|
||||
pub use session::Session;
|
||||
pub use tenant::Tenant;
|
||||
pub use user::{User, UserStatus};
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Permission {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Role {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ServiceAccount {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// A session row from the `sessions` table.
|
||||
///
|
||||
/// `token_hash` is the BLAKE3 hex-encoded hash of the raw session token.
|
||||
/// The raw token is stored in a cookie and never persisted.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
/// BLAKE3 hex hash of the raw cookie value.
|
||||
#[serde(skip_serializing)]
|
||||
pub token_hash: String,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: String,
|
||||
/// Absolute expiry — the session is dead after this regardless of activity.
|
||||
pub expires_at: String,
|
||||
/// Idle timeout — updated on each authenticated request.
|
||||
pub last_seen_at: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Tenant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Tenant {
|
||||
pub const DEFAULT_ID: &'static str = "00000000-0000-0000-0000-000000000001";
|
||||
pub const DEFAULT_SLUG: &'static str = "default";
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// User account status.
|
||||
///
|
||||
/// Stored as INTEGER in SQLite: 1 = Active, 2 = Disabled, 3 = Locked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UserStatus {
|
||||
Active = 1,
|
||||
Disabled = 2,
|
||||
Locked = 3,
|
||||
}
|
||||
|
||||
impl UserStatus {
|
||||
pub fn from_i32(v: i32) -> Self {
|
||||
match v {
|
||||
2 => Self::Disabled,
|
||||
3 => Self::Locked,
|
||||
_ => Self::Active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_i32(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Disabled => "disabled",
|
||||
Self::Locked => "locked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A user account row from the `users` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub username: String,
|
||||
/// Argon2id PHC string — never expose in API responses.
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
/// Raw integer status — use `status()` for the typed enum.
|
||||
pub status: i32,
|
||||
pub last_login_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Typed status accessor.
|
||||
pub fn status(&self) -> UserStatus {
|
||||
UserStatus::from_i32(self.status)
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.status() == UserStatus::Active
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Application;
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> Result<Application, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>(
|
||||
r#"
|
||||
INSERT INTO applications (id, tenant_id, name, slug)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(tenant_id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_slug(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE slug = ?")
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE tenant_id = ? ORDER BY name")
|
||||
.bind(tenant_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::AuditLog;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn insert(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
actor_user_id: Option<&str>,
|
||||
target_user_id: Option<&str>,
|
||||
action: &str,
|
||||
resource_type: &str,
|
||||
resource_id: Option<&str>,
|
||||
severity: &str,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
) -> Result<AuditLog, sqlx::Error> {
|
||||
sqlx::query_as::<_, AuditLog>(
|
||||
r#"
|
||||
INSERT INTO audit_logs (
|
||||
id, actor_user_id, target_user_id,
|
||||
action, resource_type, resource_id,
|
||||
severity, ip_address, user_agent, metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(actor_user_id)
|
||||
.bind(target_user_id)
|
||||
.bind(action)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.bind(severity)
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(metadata_json)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?")
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod applications;
|
||||
pub mod audit;
|
||||
pub mod permissions;
|
||||
pub mod roles;
|
||||
pub mod service_accounts;
|
||||
pub mod sessions;
|
||||
pub mod tokens;
|
||||
pub mod users;
|
||||
@@ -0,0 +1,41 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Return all permission names held by a user (via their roles).
|
||||
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT DISTINCT p.name
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.name
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(name,)| name).collect())
|
||||
}
|
||||
|
||||
/// Check if a user holds a specific named permission.
|
||||
pub async fn user_has_permission(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
permission_name: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||
WHERE ur.user_id = ? AND p.name = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(permission_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0 > 0)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Role;
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<Role>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Role>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = ?")
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Role>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Role>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Role>(
|
||||
r#"
|
||||
SELECT r.* FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn assign_to_user(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
user_id: &str,
|
||||
role_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)")
|
||||
.bind(user_id)
|
||||
.bind(role_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_from_user(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
user_id: &str,
|
||||
role_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
|
||||
.bind(user_id)
|
||||
.bind(role_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn admin_role_exists(pool: &SqlitePool) -> Result<bool, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0 > 0)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::ServiceAccount;
|
||||
|
||||
pub async fn create(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<ServiceAccount, sqlx::Error> {
|
||||
sqlx::query_as::<_, ServiceAccount>(
|
||||
r#"
|
||||
INSERT INTO service_accounts (id, tenant_id, name, description)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(tenant_id)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
) -> Result<Option<ServiceAccount>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ServiceAccount>(
|
||||
"SELECT * FROM service_accounts WHERE tenant_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE service_accounts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Session;
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
token_hash: &str,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
expires_at: &str,
|
||||
) -> Result<Session, sqlx::Error> {
|
||||
sqlx::query_as::<_, Session>(
|
||||
r#"
|
||||
INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.bind(token_hash)
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(expires_at)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_token_hash(
|
||||
pool: &SqlitePool,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<Session>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0")
|
||||
.bind(token_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_all_for_user(pool: &SqlitePool, user_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_last_seen(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete sessions that are expired or revoked. Called once at startup.
|
||||
pub async fn cleanup_expired(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM sessions
|
||||
WHERE revoked = 1
|
||||
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::ApiToken;
|
||||
|
||||
pub async fn create(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &str,
|
||||
expires_at: Option<&str>,
|
||||
) -> Result<ApiToken, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApiToken>(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.bind(name)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_hash(
|
||||
pool: &SqlitePool,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<ApiToken>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked = 0")
|
||||
.bind(token_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApiToken>(
|
||||
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<ApiToken>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_last_used(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::User;
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_username(
|
||||
pool: &SqlitePool,
|
||||
username: &str,
|
||||
) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC")
|
||||
.bind(tenant_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (id, tenant_id, username, password_hash, status)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(tenant_id)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
status: i32,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_password_hash(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(password_hash)
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_last_login(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn username_exists(
|
||||
pool: &SqlitePool,
|
||||
tenant_id: &str,
|
||||
username: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND username = ?")
|
||||
.bind(tenant_id)
|
||||
.bind(username)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0 > 0)
|
||||
}
|
||||
|
||||
/// Count users that have the admin role.
|
||||
pub async fn count_admins(pool: &SqlitePool) -> Result<i64, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT ur.user_id)
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE r.name = 'admin'
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
Reference in new issue
Block a user