feat(manager-core): repos + admin patch for Phase 3.5 schema
* admin_user_repo: surface instance_role + email on AdminUserRow / Credentials; create() now takes instance_role; add update_instance_role, list_active_owners, count_other_active_owners. * admin_users_api: DTO + create/patch accept instance_role (defaults to Admin on create — only env-var bootstrap defaults to Owner). PATCH and DELETE enforce the last-owner guard alongside the existing last-active-admin guard. * app_members_repo: new — implements AuthzRepo::membership via the app_members table plus upsert/remove/list_for_user/list_for_app. * api_key_repo: new — create / find_active_by_prefix / touch_last_used / list_for_user / get / delete_by_id_and_user / expire_all_for_user. Separates ApiKeyRow (no hash) from ApiKeyVerification (hash, for the middleware verifier) so handlers can't leak the hash. * auth_bootstrap + picloud tests: pass Owner on the bootstrap seed and on the test admin seed respectively; in-memory test repo implements the new trait methods. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
|
||||
|
||||
#[error("username already taken: {0}")]
|
||||
DuplicateUsername(String),
|
||||
|
||||
#[error("email already taken: {0}")]
|
||||
DuplicateEmail(String),
|
||||
|
||||
#[error("invalid instance_role stored in DB: {0}")]
|
||||
InvalidInstanceRole(String),
|
||||
}
|
||||
|
||||
/// Row returned to handlers and bootstrap. Never includes the password
|
||||
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -58,10 +67,14 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||
/// explicit role.
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn update_username(
|
||||
&self,
|
||||
@@ -73,6 +86,14 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
id: AdminUserId,
|
||||
password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||
/// before invoking when role transitions away from `Owner`.
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn set_active(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
@@ -90,6 +111,15 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
/// All active owners — used for the multi-owner startup warning.
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Count of active owners excluding the given id. Used by the
|
||||
/// last-owner guard when demoting / deactivating / deleting an
|
||||
/// owner: "would this leave zero owners?"
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAdminUserRepository {
|
||||
@@ -107,13 +137,14 @@ impl PostgresAdminUserRepository {
|
||||
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_by_username(
|
||||
@@ -121,13 +152,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_credentials_by_username(
|
||||
@@ -135,42 +167,46 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||
"SELECT id, username, password_hash, is_active \
|
||||
"SELECT id, username, password_hash, is_active, instance_role \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"INSERT INTO admin_users (username, password_hash) \
|
||||
VALUES ($1, $2) \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
"INSERT INTO admin_users (username, password_hash, instance_role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(instance_role.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Ok(row) => row.try_into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
),
|
||||
@@ -186,7 +222,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(username)
|
||||
@@ -194,7 +231,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(Some(row)) => row.try_into(),
|
||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
@@ -211,14 +248,34 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(password_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(instance_role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn set_active(
|
||||
@@ -229,14 +286,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(is_active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
@@ -277,6 +335,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' \
|
||||
ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' AND id <> $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -284,21 +369,27 @@ struct AdminUserRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
email: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<AdminUserRecord> for AdminUserRow {
|
||||
fn from(r: AdminUserRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||
.ok_or(AdminUserRepositoryError::InvalidInstanceRole(r.instance_role))?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +399,19 @@ struct AdminCredsRecord {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
}
|
||||
|
||||
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||
fn from(r: AdminCredsRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
password_hash: r.password_hash,
|
||||
is_active: r.is_active,
|
||||
}
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||
.ok_or(AdminUserRepositoryError::InvalidInstanceRole(r.instance_role))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user