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

@@ -20,6 +20,8 @@ use picloud_shared::AdminUserId;
use serde::{Deserialize, Serialize};
use serde_json::json;
use picloud_shared::InstanceRole;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::auth::hash_password;
@@ -57,6 +59,8 @@ pub struct AdminDto {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
@@ -67,6 +71,8 @@ impl From<AdminUserRow> for AdminDto {
id: r.id,
username: r.username,
is_active: r.is_active,
instance_role: r.instance_role,
email: r.email,
created_at: r.created_at,
last_login_at: r.last_login_at,
}
@@ -77,6 +83,15 @@ impl From<AdminUserRow> for AdminDto {
pub struct CreateAdminRequest {
pub username: String,
pub password: String,
/// Defaults to `Admin` when absent — minting an owner via the API
/// is a deliberate step. The env-var bootstrap path is the only
/// channel that defaults to `Owner`.
#[serde(default = "default_create_role")]
pub instance_role: InstanceRole,
}
const fn default_create_role() -> InstanceRole {
InstanceRole::Admin
}
#[derive(Debug, Deserialize, Default)]
@@ -84,6 +99,7 @@ pub struct PatchAdminRequest {
pub username: Option<String>,
pub password: Option<String>,
pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
}
// ----------------------------------------------------------------------------
@@ -118,7 +134,10 @@ async fn create_admin(
validate_username(username)?;
validate_password(&input.password)?;
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
let row = state.users.create(username, &hash).await?;
let row = state
.users
.create(username, &hash, input.instance_role)
.await?;
Ok((StatusCode::CREATED, Json(row.into())))
}
@@ -129,7 +148,7 @@ async fn patch_admin(
) -> Result<Json<AdminDto>, AdminApiError> {
// Verify the target exists upfront — keeps the error path uniform
// for "rename a missing user" etc.
let _ = state
let current = state
.users
.get(id)
.await?
@@ -154,6 +173,20 @@ async fn patch_admin(
// for the initial cut.)
}
if let Some(new_role) = input.instance_role {
// Last-active-owner guard: a transition off of `Owner` cannot
// leave the install with zero owners. The check is on the
// source role (current.instance_role) so demoting an
// already-non-owner is always fine.
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
let remaining = state.users.count_other_active_owners(id).await?;
if remaining == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
latest = Some(state.users.update_instance_role(id, new_role).await?);
}
if let Some(new_active) = input.is_active {
// Last-active-admin guard: only when transitioning to inactive.
if !new_active {
@@ -161,10 +194,25 @@ async fn patch_admin(
if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin);
}
// ALSO: if the target is currently the last active owner,
// deactivating them leaves no owner. Belt-and-suspenders to
// the role guard above (which only triggers on an explicit
// role transition).
let target_role = latest
.as_ref()
.map_or(current.instance_role, |r| r.instance_role);
if target_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
}
latest = Some(state.users.set_active(id, new_active).await?);
// Deactivation invalidates all of the user's sessions. Cheap
// and safer than waiting for sliding-window expiry.
// and safer than waiting for sliding-window expiry. API key
// expiry on deactivation is wired in the api_keys cascade
// step (see blueprint §11.6 "Deactivation Symmetry").
if !new_active {
if let Err(err) = state.sessions.delete_for_user(id).await {
tracing::error!(?err, "failed to delete sessions for deactivated admin");
@@ -197,9 +245,18 @@ async fn delete_admin(
if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin);
}
// Last-owner guard mirrors the role-transition guard in
// patch_admin — deleting the only owner is just as bad as
// demoting them.
if target.instance_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
}
state.users.delete(id).await?;
// Sessions cascade via FK; no explicit delete needed.
// Sessions + api_keys cascade via FK; no explicit delete needed.
Ok(StatusCode::NO_CONTENT)
}
@@ -252,6 +309,9 @@ pub enum AdminApiError {
#[error("cannot leave the system with zero active admins")]
LastActiveAdmin,
#[error("cannot leave the system with zero active owners")]
LastActiveOwner,
#[error("failed to hash password: {0}")]
Hash(String),
@@ -263,10 +323,15 @@ impl IntoResponse for AdminApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
(StatusCode::CONFLICT, self.to_string())
}
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
Self::Repo(
AdminUserRepositoryError::DuplicateUsername(_)
| AdminUserRepositoryError::DuplicateEmail(_),
) => (StatusCode::CONFLICT, self.to_string()),
Self::InvalidUsername(_)
| Self::InvalidPassword(_)
| Self::LastActiveAdmin
| Self::LastActiveOwner
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {