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:
@@ -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(_)) => {
|
||||
|
||||
Reference in New Issue
Block a user