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:
MechaCat02
2026-05-26 21:49:54 +02:00
parent abaabb68d8
commit 44db8d107a
7 changed files with 746 additions and 41 deletions

View File

@@ -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))?,
})
}
}