Initial public release v0.1.0

This commit is contained in:
thakares committed 2026-06-21 20:05:29 +05:30
commit 6c39e0bfbf
87 files changed
+10867

No files matched your search

+10
View File
@@ -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);
+16
View File
@@ -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
);
+5
View File
@@ -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);
+68
View File
@@ -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;
+20
View File
@@ -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,
}
+13
View File
@@ -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,
}
+55
View File
@@ -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,
}
+20
View File
@@ -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};
+9
View File
@@ -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>,
}
+9
View File
@@ -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>,
}
+13
View File
@@ -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,
}
+23
View File
@@ -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,
}
+17
View File
@@ -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";
}
+68
View File
@@ -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
}
}
+60
View File
@@ -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(())
}
+49
View File
@@ -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
}
+8
View File
@@ -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;
+41
View File
@@ -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)
}
+70
View File
@@ -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)
}
+59
View File
@@ -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(())
}
+79
View File
@@ -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())
}
+74
View File
@@ -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(())
}
+121
View File
@@ -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)
}