From 44db8d107ae994baf25d195a576b7c02c07fe2b8 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 26 May 2026 21:49:54 +0200 Subject: [PATCH] feat(manager-core): repos + admin patch for Phase 3.5 schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- crates/manager-core/src/admin_user_repo.rs | 153 ++++++++-- crates/manager-core/src/admin_users_api.rs | 81 +++++- crates/manager-core/src/api_key_repo.rs | 293 ++++++++++++++++++++ crates/manager-core/src/app_members_repo.rs | 215 ++++++++++++++ crates/manager-core/src/auth_bootstrap.rs | 33 ++- crates/manager-core/src/lib.rs | 9 + crates/picloud/tests/api.rs | 3 +- 7 files changed, 746 insertions(+), 41 deletions(-) create mode 100644 crates/manager-core/src/api_key_repo.rs create mode 100644 crates/manager-core/src/app_members_repo.rs diff --git a/crates/manager-core/src/admin_user_repo.rs b/crates/manager-core/src/admin_user_repo.rs index 08c20ea..122d029 100644 --- a/crates/manager-core/src/admin_user_repo.rs +++ b/crates/manager-core/src/admin_user_repo.rs @@ -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, pub created_at: DateTime, pub updated_at: DateTime, pub last_login_at: Option>, @@ -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, AdminUserRepositoryError>; async fn list(&self) -> Result, 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; async fn update_username( &self, @@ -73,6 +86,14 @@ pub trait AdminUserRepository: Send + Sync { id: AdminUserId, password_hash: &str, ) -> Result; + /// 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; async fn set_active( &self, id: AdminUserId, @@ -90,6 +111,15 @@ pub trait AdminUserRepository: Send + Sync { &self, id: AdminUserId, ) -> Result; + /// All active owners — used for the multi-owner startup warning. + async fn list_active_owners(&self) -> Result, 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; } pub struct PostgresAdminUserRepository { @@ -107,13 +137,14 @@ impl PostgresAdminUserRepository { impl AdminUserRepository for PostgresAdminUserRepository { async fn get(&self, id: AdminUserId) -> Result, 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, 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, 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, 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 { 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 { + 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, 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 { + 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, created_at: DateTime, updated_at: DateTime, last_login_at: Option>, } -impl From for AdminUserRow { - fn from(r: AdminUserRecord) -> Self { - Self { +impl TryFrom for AdminUserRow { + type Error = AdminUserRepositoryError; + fn try_from(r: AdminUserRecord) -> Result { + 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 for AdminUserCredentials { - fn from(r: AdminCredsRecord) -> Self { - Self { +impl TryFrom for AdminUserCredentials { + type Error = AdminUserRepositoryError; + fn try_from(r: AdminCredsRecord) -> Result { + 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))?, + }) } } diff --git a/crates/manager-core/src/admin_users_api.rs b/crates/manager-core/src/admin_users_api.rs index 1194af4..e44da5c 100644 --- a/crates/manager-core/src/admin_users_api.rs +++ b/crates/manager-core/src/admin_users_api.rs @@ -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, pub created_at: DateTime, pub last_login_at: Option>, } @@ -67,6 +71,8 @@ impl From 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 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, pub password: Option, pub is_active: Option, + pub instance_role: Option, } // ---------------------------------------------------------------------------- @@ -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, 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(_)) => { diff --git a/crates/manager-core/src/api_key_repo.rs b/crates/manager-core/src/api_key_repo.rs new file mode 100644 index 0000000..3980100 --- /dev/null +++ b/crates/manager-core/src/api_key_repo.rs @@ -0,0 +1,293 @@ +//! CRUD over the `api_keys` table — backs the `Authorization: Bearer +//! pic_…` credential flow from blueprint §11.6. +//! +//! The repo never sees the raw token; only the 8-char `prefix` and the +//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split, +//! hash compute) lives in `api_keys_api.rs`. Verification logic +//! (prefix lookup + Argon2 verify per candidate) lives in +//! `auth_middleware.rs`. Both call this repo for the storage layer. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope}; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum ApiKeyRepositoryError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + + #[error("api key not found: {0}")] + NotFound(ApiKeyId), + + #[error("invalid scope stored in DB: {0}")] + InvalidScope(String), +} + +/// Insert payload — built by `api_keys_api` after generating the raw +/// token and hashing it. `hash` is an Argon2id PHC string covering the +/// body of the token (everything after `pic_`); `prefix` is the first +/// 8 chars of that body, indexed for fast candidate lookup. +#[derive(Debug, Clone)] +pub struct NewApiKey { + pub user_id: AdminUserId, + pub hash: String, + pub prefix: String, + pub name: String, + pub scopes: Vec, + pub app_id: Option, + pub expires_at: Option>, +} + +/// Public-facing row — never exposes the hash. Used for `GET +/// /admin/api-keys` and the `POST` response (alongside the +/// one-shot raw token). +#[derive(Debug, Clone)] +pub struct ApiKeyRow { + pub id: ApiKeyId, + pub user_id: AdminUserId, + pub prefix: String, + pub name: String, + pub scopes: Vec, + pub app_id: Option, + pub expires_at: Option>, + pub last_used_at: Option>, + pub created_at: DateTime, +} + +/// Verification candidate — includes the Argon2id `hash` and `user_id` +/// so middleware can verify the supplied token and assemble the +/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak +/// the hash through a careless `Json(row)`. +#[derive(Debug, Clone)] +pub struct ApiKeyVerification { + pub id: ApiKeyId, + pub user_id: AdminUserId, + pub hash: String, + pub scopes: Vec, + pub app_id: Option, +} + +#[async_trait] +pub trait ApiKeyRepository: Send + Sync { + /// Mint. Caller has already hashed the raw token + computed prefix. + async fn create(&self, key: NewApiKey) -> Result; + + /// Return every non-expired key with the given 8-char prefix. The + /// caller (middleware) Argon2-verifies the supplied token against + /// each candidate's `hash`. Returning a Vec rather than one row + /// keeps the contract correct even if two keys happen to share a + /// prefix (statistically near-zero but possible). + async fn find_active_by_prefix( + &self, + prefix: &str, + ) -> Result, ApiKeyRepositoryError>; + + /// Update `last_used_at` for an authenticated request. Inline (not + /// fire-and-forget) so a DB blip surfaces as a 500 rather than + /// silent stale timestamps. + async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>; + + /// Caller's own keys, for `GET /admin/api-keys`. + async fn list_for_user( + &self, + user_id: AdminUserId, + ) -> Result, ApiKeyRepositoryError>; + + /// Look up a key by id — used by `DELETE` to verify ownership + /// before issuing the delete. + async fn get(&self, id: ApiKeyId) -> Result, ApiKeyRepositoryError>; + + /// Delete the row only if it belongs to `user_id`. Returns whether + /// a row was actually deleted (false = key didn't exist OR wasn't + /// theirs — handlers map both to 404 to avoid leaking the + /// distinction). + async fn delete_by_id_and_user( + &self, + id: ApiKeyId, + user_id: AdminUserId, + ) -> Result; + + /// Set `expires_at = NOW()` on every active key for a user. Wired + /// into `set_active(false)` so deactivation invalidates both + /// sessions (already done by `AdminSessionRepository::delete_for_user`) + /// and bearer keys at the same moment. + async fn expire_all_for_user( + &self, + user_id: AdminUserId, + ) -> Result; +} + +pub struct PostgresApiKeyRepository { + pool: PgPool, +} + +impl PostgresApiKeyRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ApiKeyRepository for PostgresApiKeyRepository { + async fn create(&self, key: NewApiKey) -> Result { + let scope_strings: Vec = key.scopes.iter().map(|s| s.as_str().to_string()).collect(); + let row = sqlx::query_as::<_, ApiKeyRecord>( + "INSERT INTO api_keys \ + (user_id, hash, prefix, name, scopes, app_id, expires_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + RETURNING id, user_id, prefix, name, scopes, app_id, \ + expires_at, last_used_at, created_at", + ) + .bind(key.user_id.into_inner()) + .bind(&key.hash) + .bind(&key.prefix) + .bind(&key.name) + .bind(&scope_strings) + .bind(key.app_id.map(picloud_shared::AppId::into_inner)) + .bind(key.expires_at) + .fetch_one(&self.pool) + .await?; + row.try_into() + } + + async fn find_active_by_prefix( + &self, + prefix: &str, + ) -> Result, ApiKeyRepositoryError> { + let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>( + "SELECT id, user_id, hash, scopes, app_id \ + FROM api_keys \ + WHERE prefix = $1 \ + AND (expires_at IS NULL OR expires_at > NOW())", + ) + .bind(prefix) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TryInto::try_into).collect() + } + + async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> { + sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1") + .bind(id.into_inner()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_for_user( + &self, + user_id: AdminUserId, + ) -> Result, ApiKeyRepositoryError> { + let rows = sqlx::query_as::<_, ApiKeyRecord>( + "SELECT id, user_id, prefix, name, scopes, app_id, \ + expires_at, last_used_at, created_at \ + FROM api_keys WHERE user_id = $1 \ + ORDER BY created_at DESC", + ) + .bind(user_id.into_inner()) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TryInto::try_into).collect() + } + + async fn get(&self, id: ApiKeyId) -> Result, ApiKeyRepositoryError> { + let row = sqlx::query_as::<_, ApiKeyRecord>( + "SELECT id, user_id, prefix, name, scopes, app_id, \ + expires_at, last_used_at, created_at \ + FROM api_keys WHERE id = $1", + ) + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await?; + row.map(TryInto::try_into).transpose() + } + + async fn delete_by_id_and_user( + &self, + id: ApiKeyId, + user_id: AdminUserId, + ) -> Result { + let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2") + .bind(id.into_inner()) + .bind(user_id.into_inner()) + .execute(&self.pool) + .await?; + Ok(res.rows_affected() > 0) + } + + async fn expire_all_for_user( + &self, + user_id: AdminUserId, + ) -> Result { + let res = sqlx::query( + "UPDATE api_keys \ + SET expires_at = NOW() \ + WHERE user_id = $1 \ + AND (expires_at IS NULL OR expires_at > NOW())", + ) + .bind(user_id.into_inner()) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} + +#[derive(sqlx::FromRow)] +struct ApiKeyRecord { + id: uuid::Uuid, + user_id: uuid::Uuid, + prefix: String, + name: String, + scopes: Vec, + app_id: Option, + expires_at: Option>, + last_used_at: Option>, + created_at: DateTime, +} + +impl TryFrom for ApiKeyRow { + type Error = ApiKeyRepositoryError; + fn try_from(r: ApiKeyRecord) -> Result { + Ok(Self { + id: r.id.into(), + user_id: r.user_id.into(), + prefix: r.prefix, + name: r.name, + scopes: parse_scopes(r.scopes)?, + app_id: r.app_id.map(Into::into), + expires_at: r.expires_at, + last_used_at: r.last_used_at, + created_at: r.created_at, + }) + } +} + +#[derive(sqlx::FromRow)] +struct ApiKeyVerifyRecord { + id: uuid::Uuid, + user_id: uuid::Uuid, + hash: String, + scopes: Vec, + app_id: Option, +} + +impl TryFrom for ApiKeyVerification { + type Error = ApiKeyRepositoryError; + fn try_from(r: ApiKeyVerifyRecord) -> Result { + Ok(Self { + id: r.id.into(), + user_id: r.user_id.into(), + hash: r.hash, + scopes: parse_scopes(r.scopes)?, + app_id: r.app_id.map(Into::into), + }) + } +} + +fn parse_scopes(raw: Vec) -> Result, ApiKeyRepositoryError> { + raw.into_iter() + .map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s))) + .collect() +} diff --git a/crates/manager-core/src/app_members_repo.rs b/crates/manager-core/src/app_members_repo.rs new file mode 100644 index 0000000..a018299 --- /dev/null +++ b/crates/manager-core/src/app_members_repo.rs @@ -0,0 +1,215 @@ +//! CRUD over the `app_members` table — explicit per-(user, app) role +//! grants for `member` instance-role users. Owners and admins do NOT +//! appear here; their app authority is implicit (see authz.rs). +//! +//! Doubles as the production `AuthzRepo` implementation: the +//! membership lookup `can()` needs is the same single-row SELECT as +//! `find` here. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::{AdminUserId, AppId, AppRole}; +use sqlx::PgPool; + +use crate::authz::{AuthzError, AuthzRepo}; + +#[derive(Debug, thiserror::Error)] +pub enum AppMembersRepositoryError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + + #[error("membership row not found: app={app_id}, user={user_id}")] + NotFound { + app_id: AppId, + user_id: AdminUserId, + }, + + #[error("invalid app_role stored in DB: {0}")] + InvalidRole(String), +} + +/// One row of `app_members`. Returned by `list_for_user` / `list_for_app` +/// so handlers can render the cross-reference without joining to apps +/// or admin_users themselves. +#[derive(Debug, Clone)] +pub struct AppMembershipRow { + pub app_id: AppId, + pub user_id: AdminUserId, + pub role: AppRole, + pub created_at: DateTime, +} + +#[async_trait] +pub trait AppMembersRepository: Send + Sync { + /// Single (user, app) lookup. Returns `None` for non-members and + /// for unrelated apps. This is the hot path for `authz::can`. + async fn find( + &self, + user_id: AdminUserId, + app_id: AppId, + ) -> Result, AppMembersRepositoryError>; + + /// Upsert a membership. Used both for first-time grants and role + /// promotions/demotions on an existing row. + async fn upsert( + &self, + app_id: AppId, + user_id: AdminUserId, + role: AppRole, + ) -> Result; + + /// Remove a membership. No-op (Ok) when the row doesn't exist — + /// the user wasn't a member, which is the desired post-condition. + async fn remove( + &self, + app_id: AppId, + user_id: AdminUserId, + ) -> Result<(), AppMembersRepositoryError>; + + /// Every membership the user holds. Drives the membership-filtered + /// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for + /// `member` callers). + async fn list_for_user( + &self, + user_id: AdminUserId, + ) -> Result, AppMembersRepositoryError>; + + /// Every membership on a given app. Used by `GET + /// /admin/apps/{id}/members` once that surface lands; included now + /// so the trait is complete enough for tests. + async fn list_for_app( + &self, + app_id: AppId, + ) -> Result, AppMembersRepositoryError>; +} + +pub struct PostgresAppMembersRepository { + pool: PgPool, +} + +impl PostgresAppMembersRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AppMembersRepository for PostgresAppMembersRepository { + async fn find( + &self, + user_id: AdminUserId, + app_id: AppId, + ) -> Result, AppMembersRepositoryError> { + let row: Option<(String,)> = + sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2") + .bind(user_id.into_inner()) + .bind(app_id.into_inner()) + .fetch_optional(&self.pool) + .await?; + row.map(|(role,)| { + AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role)) + }) + .transpose() + } + + async fn upsert( + &self, + app_id: AppId, + user_id: AdminUserId, + role: AppRole, + ) -> Result { + let row = sqlx::query_as::<_, AppMembershipRecord>( + "INSERT INTO app_members (app_id, user_id, role) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \ + RETURNING app_id, user_id, role, created_at", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .bind(role.as_str()) + .fetch_one(&self.pool) + .await?; + row.try_into() + } + + async fn remove( + &self, + app_id: AppId, + user_id: AdminUserId, + ) -> Result<(), AppMembersRepositoryError> { + sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2") + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_for_user( + &self, + user_id: AdminUserId, + ) -> Result, AppMembersRepositoryError> { + let rows = sqlx::query_as::<_, AppMembershipRecord>( + "SELECT app_id, user_id, role, created_at \ + FROM app_members WHERE user_id = $1 \ + ORDER BY created_at", + ) + .bind(user_id.into_inner()) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TryInto::try_into).collect() + } + + async fn list_for_app( + &self, + app_id: AppId, + ) -> Result, AppMembersRepositoryError> { + let rows = sqlx::query_as::<_, AppMembershipRecord>( + "SELECT app_id, user_id, role, created_at \ + FROM app_members WHERE app_id = $1 \ + ORDER BY created_at", + ) + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TryInto::try_into).collect() + } +} + +/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly +/// — handlers store a single `Arc` and pass +/// it to `authz::can` without casting. +#[async_trait] +impl AuthzRepo for PostgresAppMembersRepository { + async fn membership( + &self, + user_id: AdminUserId, + app_id: AppId, + ) -> Result, AuthzError> { + self.find(user_id, app_id) + .await + .map_err(|e| AuthzError::Repo(e.to_string())) + } +} + +#[derive(sqlx::FromRow)] +struct AppMembershipRecord { + app_id: uuid::Uuid, + user_id: uuid::Uuid, + role: String, + created_at: DateTime, +} + +impl TryFrom for AppMembershipRow { + type Error = AppMembersRepositoryError; + fn try_from(r: AppMembershipRecord) -> Result { + Ok(Self { + app_id: r.app_id.into(), + user_id: r.user_id.into(), + role: AppRole::from_db_str(&r.role) + .ok_or(AppMembersRepositoryError::InvalidRole(r.role))?, + created_at: r.created_at, + }) + } +} diff --git a/crates/manager-core/src/auth_bootstrap.rs b/crates/manager-core/src/auth_bootstrap.rs index b044806..ed984b3 100644 --- a/crates/manager-core/src/auth_bootstrap.rs +++ b/crates/manager-core/src/auth_bootstrap.rs @@ -116,7 +116,15 @@ pub async fn bootstrap_first_admin_with( (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 { 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 { unimplemented!() } + async fn update_instance_role( + &self, + _i: AdminUserId, + _r: InstanceRole, + ) -> Result { + unimplemented!() + } async fn set_active( &self, _i: AdminUserId, @@ -215,6 +233,15 @@ mod tests { ) -> Result { unimplemented!() } + async fn list_active_owners(&self) -> Result, AdminUserRepositoryError> { + unimplemented!() + } + async fn count_other_active_owners( + &self, + _i: AdminUserId, + ) -> Result { + 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()), diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index b9bc82b..026ac3d 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -8,8 +8,10 @@ pub mod admin_session_repo; pub mod admin_user_repo; pub mod admin_users_api; pub mod api; +pub mod api_key_repo; pub mod app_bootstrap; pub mod app_domain_repo; +pub mod app_members_repo; pub mod app_repo; pub mod apps_api; pub mod auth; @@ -35,8 +37,15 @@ pub use admin_user_repo::{ }; pub use admin_users_api::{admins_router, AdminsState}; pub use api::{admin_router, AdminState}; +pub use api_key_repo::{ + ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey, + PostgresApiKeyRepository, +}; pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome}; pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository}; +pub use app_members_repo::{ + AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository, +}; pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository}; pub use apps_api::{apps_router, AppsState}; pub use auth_api::auth_router; diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index 87f4e7a..cb4b75a 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -31,11 +31,12 @@ async fn server(pool: PgPool) -> TestServer { /// any test that creates scripts (every script now requires `app_id`). async fn server_with_app(pool: PgPool) -> (TestServer, String) { use picloud_manager_core::auth::hash_password; + use picloud_shared::InstanceRole; let auth = picloud::AuthDeps::from_pool(pool.clone()); let hash = hash_password("test-pw").expect("hash"); auth.users - .create("test-admin", &hash) + .create("test-admin", &hash, InstanceRole::Owner) .await .expect("seed admin");