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

@@ -116,7 +116,15 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
(None, None) => return Err(BootstrapError::MissingPassword),
};
repo.create(&username, &password_hash).await?;
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
// first row to full instance control. Subsequent admins minted via
// the API default to Admin and can be promoted explicitly.
repo.create(
&username,
&password_hash,
picloud_shared::InstanceRole::Owner,
)
.await?;
info!(username = %username, "bootstrapped initial admin user");
Ok(())
}
@@ -130,7 +138,7 @@ mod tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::AdminUserId;
use picloud_shared::{AdminUserId, InstanceRole};
use std::sync::Mutex;
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
@@ -167,11 +175,14 @@ mod tests {
&self,
username: &str,
_password_hash: &str,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow {
id: AdminUserId::new(),
username: username.to_string(),
is_active: true,
instance_role,
email: None,
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
@@ -193,6 +204,13 @@ mod tests {
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
_r: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn set_active(
&self,
_i: AdminUserId,
@@ -215,6 +233,15 @@ mod tests {
) -> Result<i64, AdminUserRepositoryError> {
unimplemented!()
}
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn count_other_active_owners(
&self,
_i: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
unimplemented!()
}
}
#[tokio::test]
@@ -245,7 +272,7 @@ mod tests {
#[tokio::test]
async fn populated_db_is_noop() {
let repo = InMemoryRepo::default();
repo.create("seeded", "x").await.unwrap();
repo.create("seeded", "x", InstanceRole::Owner).await.unwrap();
let env = BootstrapEnv {
username: Some("alice".into()),
password: Some("supersecret".into()),