mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(db): add UserStore trait with users, api_tokens, invitations tables
Foundation for DB-backed user management (#1605): - UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs - UserStore sub-trait (17 methods) added to Database supertrait - PostgreSQL migration V14__users.sql (users, api_tokens, invitations) - libSQL schema + incremental migration V14 - Full implementations for both PgBackend (via Store delegation) and LibSqlBackend (direct SQL in libsql/users.rs) - authenticate_token JOINs api_tokens+users with active/non-revoked checks; has_any_users for bootstrap detection Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
-- User management tables for multi-tenant deployments.
|
||||
--
|
||||
-- Replaces the static GATEWAY_USER_TOKENS env var with DB-backed
|
||||
-- user registration, API token management, and invitation flow.
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- matches existing user_id pattern (string, not UUID)
|
||||
email TEXT UNIQUE, -- nullable for token-only users
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_by TEXT REFERENCES users(id), -- who invited this user (nullable for bootstrap)
|
||||
metadata JSONB NOT NULL DEFAULT '{}' -- extensible profile data
|
||||
);
|
||||
|
||||
CREATE TABLE api_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BYTEA NOT NULL, -- SHA-256 hash (never store plaintext)
|
||||
token_prefix TEXT NOT NULL, -- first 8 hex chars for display
|
||||
name TEXT NOT NULL, -- human label ("my-laptop", "ci-bot")
|
||||
expires_at TIMESTAMPTZ, -- nullable = never expires
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ -- soft-revoke: set this instead of deleting
|
||||
);
|
||||
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE invitations (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT, -- nullable for link-based invites
|
||||
invite_token_hash BYTEA NOT NULL, -- SHA-256 hash of the invite link token
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | expired | revoked
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
accepted_at TIMESTAMPTZ,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -12,6 +12,7 @@ mod routines;
|
||||
mod sandbox;
|
||||
mod settings;
|
||||
mod tool_failures;
|
||||
mod users;
|
||||
mod workspace;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
//! UserStore implementation for LibSqlBackend.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use libsql::params;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text};
|
||||
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: serde_json::Value = serde_json::from_str(&metadata_str)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
Ok(UserRecord {
|
||||
id: get_text(row, 0),
|
||||
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),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_api_token(row: &libsql::Row) -> ApiTokenRecord {
|
||||
ApiTokenRecord {
|
||||
id: get_text(row, 0).parse().unwrap_or_default(),
|
||||
user_id: get_text(row, 1),
|
||||
name: get_text(row, 2),
|
||||
token_prefix: get_text(row, 3),
|
||||
expires_at: get_opt_ts(row, 4),
|
||||
last_used_at: get_opt_ts(row, 5),
|
||||
created_at: get_ts(row, 6),
|
||||
revoked_at: get_opt_ts(row, 7),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_invitation(row: &libsql::Row) -> InvitationRecord {
|
||||
InvitationRecord {
|
||||
id: get_text(row, 0).parse().unwrap_or_default(),
|
||||
email: get_opt_text(row, 1),
|
||||
invited_by: get_text(row, 2),
|
||||
status: get_text(row, 3),
|
||||
expires_at: get_ts(row, 4),
|
||||
accepted_at: get_opt_ts(row, 5),
|
||||
accepted_by: get_opt_text(row, 6),
|
||||
created_at: get_ts(row, 7),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserStore for LibSqlBackend {
|
||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let metadata_json = serde_json::to_string(&user.metadata)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
|
||||
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)
|
||||
"#,
|
||||
params![
|
||||
user.id.as_str(),
|
||||
opt_text(user.email.as_deref()),
|
||||
user.display_name.as_str(),
|
||||
user.status.as_str(),
|
||||
fmt_ts(&user.created_at),
|
||||
fmt_ts(&user.updated_at),
|
||||
fmt_opt_ts(&user.last_login_at),
|
||||
opt_text(user.created_by.as_deref()),
|
||||
metadata_json,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE id = ?1
|
||||
"#,
|
||||
params![id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(Some(row_to_user(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE email = ?1
|
||||
"#,
|
||||
params![email],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(Some(row_to_user(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut users = Vec::new();
|
||||
|
||||
let mut rows = if let Some(status) = status {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users WHERE status = ?1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
params![status],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, display_name, status, created_at, updated_at,
|
||||
last_login_at, created_by, metadata
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
};
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
users.push(row_to_user(&row)?);
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
"UPDATE users SET status = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
params![id, status, now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
let metadata_json = serde_json::to_string(metadata)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name = ?2, metadata = ?3, updated_at = ?4 WHERE id = ?1",
|
||||
params![id, display_name, metadata_json, now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at = ?2, updated_at = ?2 WHERE id = ?1",
|
||||
params![id, now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
"#,
|
||||
params![
|
||||
id.to_string(),
|
||||
user_id,
|
||||
libsql::Value::Blob(token_hash.to_vec()),
|
||||
token_prefix,
|
||||
name,
|
||||
fmt_opt_ts(&expires_at),
|
||||
fmt_ts(&now),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
Ok(ApiTokenRecord {
|
||||
id,
|
||||
user_id: user_id.to_string(),
|
||||
name: name.to_string(),
|
||||
token_prefix: token_prefix.to_string(),
|
||||
expires_at,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
revoked_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
|
||||
FROM api_tokens WHERE user_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
params![user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
let mut tokens = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
tokens.push(row_to_api_token(&row));
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
let rows_affected = conn
|
||||
.execute(
|
||||
r#"
|
||||
UPDATE api_tokens SET revoked_at = ?3
|
||||
WHERE id = ?1 AND user_id = ?2 AND revoked_at IS NULL
|
||||
"#,
|
||||
params![token_id.to_string(), user_id, now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
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.updated_at, u.last_login_at, u.created_by, u.metadata
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ?1
|
||||
AND t.revoked_at IS NULL
|
||||
AND (t.expires_at IS NULL OR t.expires_at > ?2)
|
||||
AND u.status = 'active'
|
||||
"#,
|
||||
params![libsql::Value::Blob(token_hash.to_vec()), now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => {
|
||||
let token = ApiTokenRecord {
|
||||
id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
user_id: get_text(&row, 1),
|
||||
name: get_text(&row, 2),
|
||||
token_prefix: get_text(&row, 3),
|
||||
expires_at: get_opt_ts(&row, 4),
|
||||
last_used_at: get_opt_ts(&row, 5),
|
||||
created_at: get_ts(&row, 6),
|
||||
revoked_at: get_opt_ts(&row, 7),
|
||||
};
|
||||
|
||||
let metadata_str = get_text(&row, 16);
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
|
||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
||||
|
||||
let user = UserRecord {
|
||||
id: get_text(&row, 8),
|
||||
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),
|
||||
metadata,
|
||||
};
|
||||
|
||||
Ok(Some((token, user)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at = ?2 WHERE id = ?1",
|
||||
params![token_id.to_string(), now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO invitations (id, email, invite_token_hash, invited_by, status, expires_at, accepted_at, accepted_by, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
"#,
|
||||
params![
|
||||
invitation.id.to_string(),
|
||||
opt_text(invitation.email.as_deref()),
|
||||
libsql::Value::Blob(invite_hash.to_vec()),
|
||||
invitation.invited_by.as_str(),
|
||||
invitation.status.as_str(),
|
||||
fmt_ts(&invitation.expires_at),
|
||||
fmt_opt_ts(&invitation.accepted_at),
|
||||
opt_text(invitation.accepted_by.as_deref()),
|
||||
fmt_ts(&invitation.created_at),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations WHERE invite_token_hash = ?1
|
||||
"#,
|
||||
params![libsql::Value::Blob(invite_hash.to_vec())],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(Some(row_to_invitation(&row))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
r#"
|
||||
UPDATE invitations SET status = 'accepted', accepted_at = ?2, accepted_by = ?3
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![id.to_string(), now, accepted_by],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut invitations = Vec::new();
|
||||
|
||||
let mut rows = if let Some(invited_by) = invited_by {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations WHERE invited_by = ?1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
params![invited_by],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
};
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
invitations.push(row_to_invitation(&row));
|
||||
}
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query("SELECT 1 FROM users LIMIT 1", ())
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
let has_users = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
.is_some();
|
||||
Ok(has_users)
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,46 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
||||
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- ==================== User management (V14) ====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
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,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
"#;
|
||||
|
||||
/// Incremental migrations applied after the base schema.
|
||||
@@ -723,6 +763,49 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
|
||||
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
14,
|
||||
"users",
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
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,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
+122
@@ -309,6 +309,55 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== User management record types ====================
|
||||
|
||||
/// A registered user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserRecord {
|
||||
/// User identifier (string, matches existing `user_id` throughout the codebase).
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub display_name: String,
|
||||
/// `active`, `suspended`, or `deactivated`.
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
/// Who created/invited this user (nullable for bootstrap users).
|
||||
pub created_by: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
/// An API token for authenticating requests (hash stored, never plaintext).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiTokenRecord {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
/// Human label (e.g. "my-laptop", "ci-bot").
|
||||
pub name: String,
|
||||
/// First 8 hex chars of the plaintext token for display/identification.
|
||||
pub token_prefix: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Soft-revoke timestamp. Non-null means revoked.
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A pending invitation to create an account.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvitationRecord {
|
||||
pub id: Uuid,
|
||||
pub email: Option<String>,
|
||||
pub invited_by: String,
|
||||
/// `pending`, `accepted`, `expired`, or `revoked`.
|
||||
pub status: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub accepted_at: Option<DateTime<Utc>>,
|
||||
pub accepted_by: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ==================== Sub-traits ====================
|
||||
//
|
||||
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
||||
@@ -761,6 +810,78 @@ pub trait WorkspaceStore: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserStore: Send + Sync {
|
||||
// ---- Users ----
|
||||
|
||||
/// Create a new user record.
|
||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError>;
|
||||
/// Get a user by their string id.
|
||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError>;
|
||||
/// Get a user by email address.
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
|
||||
/// List users, optionally filtered by status.
|
||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
|
||||
/// Update a user's status (active/suspended/deactivated).
|
||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
|
||||
/// Update a user's display name and metadata.
|
||||
async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError>;
|
||||
/// Record a login timestamp.
|
||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
|
||||
|
||||
// ---- API Tokens ----
|
||||
|
||||
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
|
||||
async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError>;
|
||||
/// List tokens for a user (never includes the hash).
|
||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
|
||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
|
||||
/// Look up a token by hash, returning the token record and its owning user.
|
||||
/// Only returns active (non-revoked, non-expired) tokens for active users.
|
||||
async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
|
||||
/// Update `last_used_at` for a token.
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
|
||||
|
||||
// ---- Invitations ----
|
||||
|
||||
/// Create a new invitation.
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError>;
|
||||
/// Look up a pending invitation by its hashed token.
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError>;
|
||||
/// Accept an invitation (sets status=accepted, accepted_by, accepted_at).
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError>;
|
||||
/// List invitations, optionally filtered by inviter.
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError>;
|
||||
/// Check whether any user records exist (for first-run bootstrap detection).
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
|
||||
}
|
||||
|
||||
/// Backend-agnostic database supertrait.
|
||||
///
|
||||
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
|
||||
@@ -774,6 +895,7 @@ pub trait Database:
|
||||
+ ToolFailureStore
|
||||
+ SettingsStore
|
||||
+ WorkspaceStore
|
||||
+ UserStore
|
||||
+ Send
|
||||
+ Sync
|
||||
{
|
||||
|
||||
+104
-2
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
||||
use crate::config::DatabaseConfig;
|
||||
use crate::context::{ActionRecord, JobContext, JobState};
|
||||
use crate::db::{
|
||||
ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
|
||||
ToolFailureStore, WorkspaceStore,
|
||||
ApiTokenRecord, ConversationStore, Database, InvitationRecord, JobStore, RoutineStore,
|
||||
SandboxStore, SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
|
||||
};
|
||||
use crate::error::{DatabaseError, WorkspaceError};
|
||||
use crate::history::{
|
||||
@@ -786,3 +786,105 @@ impl WorkspaceStore for PgBackend {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UserStore ====================
|
||||
|
||||
#[async_trait]
|
||||
impl UserStore for PgBackend {
|
||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
||||
self.store.create_user(user).await
|
||||
}
|
||||
|
||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
self.store.get_user(id).await
|
||||
}
|
||||
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
self.store.get_user_by_email(email).await
|
||||
}
|
||||
|
||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
||||
self.store.list_users(status).await
|
||||
}
|
||||
|
||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
||||
self.store.update_user_status(id, status).await
|
||||
}
|
||||
|
||||
async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.store
|
||||
.update_user_profile(id, display_name, metadata)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
self.store.record_login(id).await
|
||||
}
|
||||
|
||||
async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
self.store
|
||||
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
||||
self.store.list_api_tokens(user_id).await
|
||||
}
|
||||
|
||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
|
||||
self.store.revoke_api_token(token_id, user_id).await
|
||||
}
|
||||
|
||||
async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
||||
self.store.authenticate_token(token_hash).await
|
||||
}
|
||||
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
||||
self.store.record_token_usage(token_id).await
|
||||
}
|
||||
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.store.create_invitation(invitation, invite_hash).await
|
||||
}
|
||||
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
self.store.get_invitation_by_hash(invite_hash).await
|
||||
}
|
||||
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
|
||||
self.store.accept_invitation(id, accepted_by).await
|
||||
}
|
||||
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
self.store.list_invitations(invited_by).await
|
||||
}
|
||||
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
self.store.has_any_users().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,402 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Users / API Tokens / Invitations ====================
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::db::{ApiTokenRecord, InvitationRecord, UserRecord};
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
impl Store {
|
||||
/// Create a new user record.
|
||||
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
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)
|
||||
"#,
|
||||
&[
|
||||
&user.id,
|
||||
&user.email,
|
||||
&user.display_name,
|
||||
&user.status,
|
||||
&user.created_at,
|
||||
&user.updated_at,
|
||||
&user.last_login_at,
|
||||
&user.created_by,
|
||||
&user.metadata,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a user by their string id.
|
||||
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt("SELECT * FROM users WHERE id = $1", &[&id])
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_user(&r)))
|
||||
}
|
||||
|
||||
/// Get a user by email address.
|
||||
pub async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt("SELECT * FROM users WHERE email = $1", &[&email])
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_user(&r)))
|
||||
}
|
||||
|
||||
/// List users, optionally filtered by status.
|
||||
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = match status {
|
||||
Some(s) => {
|
||||
conn.query(
|
||||
"SELECT * FROM users WHERE status = $1 ORDER BY created_at",
|
||||
&[&s],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
conn.query("SELECT * FROM users ORDER BY created_at", &[])
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.iter().map(row_to_user).collect())
|
||||
}
|
||||
|
||||
/// Update a user's status.
|
||||
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
|
||||
&[&status, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a user's display name and metadata.
|
||||
pub async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
|
||||
&[&display_name, metadata, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a login timestamp for a user.
|
||||
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new API token.
|
||||
pub async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"#,
|
||||
&[
|
||||
&id,
|
||||
&user_id,
|
||||
&token_hash.to_vec(),
|
||||
&token_prefix,
|
||||
&name,
|
||||
&expires_at,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(ApiTokenRecord {
|
||||
id,
|
||||
user_id: user_id.to_string(),
|
||||
name: name.to_string(),
|
||||
token_prefix: token_prefix.to_string(),
|
||||
expires_at,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
revoked_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// List tokens for a user.
|
||||
pub async fn list_api_tokens(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.iter().map(row_to_api_token).collect())
|
||||
}
|
||||
|
||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
||||
pub async fn revoke_api_token(
|
||||
&self,
|
||||
token_id: Uuid,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let count = conn
|
||||
.execute(
|
||||
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
|
||||
&[&token_id, &user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Authenticate a token by hash. Returns the token record and its owning user
|
||||
/// if the token is active (non-revoked, non-expired) and the user is active.
|
||||
pub async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
r#"
|
||||
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 as u_id, u.email, u.display_name, u.status, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
|
||||
FROM api_tokens t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.token_hash = $1
|
||||
AND t.revoked_at IS NULL
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||
AND u.status = 'active'
|
||||
"#,
|
||||
&[&token_hash.to_vec()],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| {
|
||||
let token = ApiTokenRecord {
|
||||
id: r.get("id"),
|
||||
user_id: r.get("user_id"),
|
||||
name: r.get("name"),
|
||||
token_prefix: r.get("token_prefix"),
|
||||
expires_at: r.get("expires_at"),
|
||||
last_used_at: r.get("last_used_at"),
|
||||
created_at: r.get("created_at"),
|
||||
revoked_at: r.get("revoked_at"),
|
||||
};
|
||||
let user = UserRecord {
|
||||
id: r.get("u_id"),
|
||||
email: r.get("email"),
|
||||
display_name: r.get("display_name"),
|
||||
status: r.get("status"),
|
||||
created_at: r.get("u_created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
last_login_at: r.get("last_login_at"),
|
||||
created_by: r.get("created_by"),
|
||||
metadata: r.get("metadata"),
|
||||
};
|
||||
(token, user)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update `last_used_at` for a token.
|
||||
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
|
||||
&[&token_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new invitation.
|
||||
pub async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO invitations (id, email, invite_token_hash, invited_by, status, expires_at, accepted_at, accepted_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
&[
|
||||
&invitation.id,
|
||||
&invitation.email,
|
||||
&invite_hash.to_vec(),
|
||||
&invitation.invited_by,
|
||||
&invitation.status,
|
||||
&invitation.expires_at,
|
||||
&invitation.accepted_at,
|
||||
&invitation.accepted_by,
|
||||
&invitation.created_at,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a pending invitation by its hashed token.
|
||||
pub async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
WHERE invite_token_hash = $1 AND status = 'pending' AND expires_at > NOW()
|
||||
"#,
|
||||
&[&invite_hash.to_vec()],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_invitation(&r)))
|
||||
}
|
||||
|
||||
/// Accept an invitation.
|
||||
pub async fn accept_invitation(
|
||||
&self,
|
||||
id: Uuid,
|
||||
accepted_by: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE invitations SET status = 'accepted', accepted_at = NOW(), accepted_by = $1 WHERE id = $2",
|
||||
&[&accepted_by, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List invitations, optionally filtered by inviter.
|
||||
pub async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = match invited_by {
|
||||
Some(user) => {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
WHERE invited_by = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[&user],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.iter().map(row_to_invitation).collect())
|
||||
}
|
||||
|
||||
/// Check whether any user records exist.
|
||||
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.get("has_users"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
|
||||
UserRecord {
|
||||
id: row.get("id"),
|
||||
email: row.get("email"),
|
||||
display_name: row.get("display_name"),
|
||||
status: row.get("status"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
last_login_at: row.get("last_login_at"),
|
||||
created_by: row.get("created_by"),
|
||||
metadata: row.get("metadata"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
|
||||
ApiTokenRecord {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
name: row.get("name"),
|
||||
token_prefix: row.get("token_prefix"),
|
||||
expires_at: row.get("expires_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
created_at: row.get("created_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_invitation(row: &tokio_postgres::Row) -> InvitationRecord {
|
||||
InvitationRecord {
|
||||
id: row.get("id"),
|
||||
email: row.get("email"),
|
||||
invited_by: row.get("invited_by"),
|
||||
status: row.get("status"),
|
||||
expires_at: row.get("expires_at"),
|
||||
accepted_at: row.get("accepted_at"),
|
||||
accepted_by: row.get("accepted_by"),
|
||||
created_at: row.get("created_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user