mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 00:29:24 +00:00
feat: add role-based access control (admin/member)
Adds a `role` field (admin|member) to user management: Schema: - `role TEXT NOT NULL DEFAULT 'member'` added to users table in both PostgreSQL V14 migration and libSQL schema/incremental migration - UserRecord gains `role: String` field - UserIdentity gains `role: String` field, populated from DB in DbAuthenticator and defaulting to "admin" for single-user mode Access control: - AdminUser extractor: returns 403 Forbidden if role != "admin" - /api/admin/users/* handlers: require AdminUser (create, list, detail, update, suspend, activate) - POST /api/invitations: requires AdminUser (only admins can invite) - User creation accepts optional "role" param (defaults to "member") - Invitation acceptance creates users with "member" role Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
+22
-17
@@ -10,7 +10,7 @@ use crate::db::libsql::LibSqlBackend;
|
||||
use crate::db::{ApiTokenRecord, DatabaseError, InvitationRecord, UserRecord, UserStore};
|
||||
|
||||
fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
|
||||
let metadata_str = get_text(row, 8);
|
||||
let metadata_str = get_text(row, 9);
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
Ok(UserRecord {
|
||||
@@ -18,10 +18,11 @@ fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
|
||||
email: get_opt_text(row, 1),
|
||||
display_name: get_text(row, 2),
|
||||
status: get_text(row, 3),
|
||||
created_at: get_ts(row, 4),
|
||||
updated_at: get_ts(row, 5),
|
||||
last_login_at: get_opt_ts(row, 6),
|
||||
created_by: get_opt_text(row, 7),
|
||||
role: get_text(row, 4),
|
||||
created_at: get_ts(row, 5),
|
||||
updated_at: get_ts(row, 6),
|
||||
last_login_at: get_opt_ts(row, 7),
|
||||
created_by: get_opt_text(row, 8),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
@@ -69,14 +70,15 @@ impl UserStore for LibSqlBackend {
|
||||
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO users (id, email, display_name, status, created_at, updated_at, last_login_at, created_by, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
"#,
|
||||
params![
|
||||
user.id.as_str(),
|
||||
opt_text(user.email.as_deref()),
|
||||
user.display_name.as_str(),
|
||||
user.status.as_str(),
|
||||
user.role.as_str(),
|
||||
fmt_ts(&user.created_at),
|
||||
fmt_ts(&user.updated_at),
|
||||
fmt_opt_ts(&user.last_login_at),
|
||||
@@ -94,7 +96,7 @@ impl UserStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE id = ?1
|
||||
"#,
|
||||
@@ -118,7 +120,7 @@ impl UserStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE email = ?1
|
||||
"#,
|
||||
@@ -144,7 +146,7 @@ impl UserStore for LibSqlBackend {
|
||||
let mut rows = if let Some(status) = status {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE status = ?1
|
||||
ORDER BY created_at DESC
|
||||
@@ -156,7 +158,7 @@ impl UserStore for LibSqlBackend {
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
@@ -316,7 +318,7 @@ impl UserStore for LibSqlBackend {
|
||||
SELECT
|
||||
t.id, t.user_id, t.name, t.token_prefix, t.expires_at,
|
||||
t.last_used_at, t.created_at, t.revoked_at,
|
||||
u.id, u.email, u.display_name, u.status, u.created_at,
|
||||
u.id, u.email, u.display_name, u.status, u.role, u.created_at,
|
||||
u.updated_at, u.last_login_at, u.created_by, u.metadata
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
@@ -351,7 +353,7 @@ impl UserStore for LibSqlBackend {
|
||||
revoked_at: get_opt_ts(&row, 7),
|
||||
};
|
||||
|
||||
let metadata_str = get_text(&row, 16);
|
||||
let metadata_str = get_text(&row, 17);
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
|
||||
@@ -360,10 +362,11 @@ impl UserStore for LibSqlBackend {
|
||||
email: get_opt_text(&row, 9),
|
||||
display_name: get_text(&row, 10),
|
||||
status: get_text(&row, 11),
|
||||
created_at: get_ts(&row, 12),
|
||||
updated_at: get_ts(&row, 13),
|
||||
last_login_at: get_opt_ts(&row, 14),
|
||||
created_by: get_opt_text(&row, 15),
|
||||
role: get_text(&row, 12),
|
||||
created_at: get_ts(&row, 13),
|
||||
updated_at: get_ts(&row, 14),
|
||||
last_login_at: get_opt_ts(&row, 15),
|
||||
created_by: get_opt_text(&row, 16),
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -541,6 +544,7 @@ mod tests {
|
||||
email: Some(format!("{}@test.com", id)),
|
||||
display_name: id.to_string(),
|
||||
status: "active".to_string(),
|
||||
role: "member".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
@@ -730,6 +734,7 @@ mod tests {
|
||||
email: Some("[email protected]".to_string()),
|
||||
display_name: "New User".to_string(),
|
||||
status: "active".to_string(),
|
||||
role: "member".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
|
||||
@@ -587,6 +587,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
@@ -775,6 +776,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
|
||||
@@ -320,6 +320,8 @@ pub struct UserRecord {
|
||||
pub display_name: String,
|
||||
/// `active`, `suspended`, or `deactivated`.
|
||||
pub status: String,
|
||||
/// `admin` or `member`.
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
|
||||
Reference in New Issue
Block a user