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:
215
crates/manager-core/src/app_members_repo.rs
Normal file
215
crates/manager-core/src/app_members_repo.rs
Normal file
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
#[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<Option<AppRole>, 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<AppMembershipRow, AppMembersRepositoryError>;
|
||||
|
||||
/// 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<Vec<AppMembershipRow>, 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<Vec<AppMembershipRow>, 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<Option<AppRole>, 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<AppMembershipRow, AppMembersRepositoryError> {
|
||||
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<Vec<AppMembershipRow>, 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<Vec<AppMembershipRow>, 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<dyn AppMembersRepository>` 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<Option<AppRole>, 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<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
||||
type Error = AppMembersRepositoryError;
|
||||
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user