feat(manager-core,picloud): per-handler require(capability) checks
Every admin endpoint now resolves Capability for the loaded resource and calls authz::require(...) before mutating. Forbidden → 403; every handler State carries an Arc<dyn AuthzRepo>, plumbed from the new PostgresAppMembersRepository in the picloud binary. * api.rs (scripts): AppRead/AppWriteScript/AppLogRead bound to script.app_id after load. List branches on instance_role: Member → list_for_user, others → list (or ?app= filtered). * apps_api.rs: InstanceCreateApp on POST; AppRead on get/list_domains; AppAdmin on patch/delete/slug:check; AppManageDomains on create_domain/delete_domain. list_apps membership-filters for Member. * admin_users_api.rs: InstanceManageUsers on every endpoint. Mint + PATCH refuse to grant Owner unless the caller is already Owner (CannotEscalate / 422), on top of the existing last-owner guard. * route_admin.rs: AppRead on list/check/match; AppWriteRoute on create/delete bound to the route's actual app_id (added a RouteRepository::get(uuid) lookup so delete binds correctly). * AppRepository + ScriptRepository gain list_for_user(user_id) for membership-filtered listings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,18 +14,17 @@ use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||
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::api_key_repo::ApiKeyRepository;
|
||||
use crate::auth::hash_password;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
|
||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||
@@ -43,6 +42,9 @@ pub struct AdminsState {
|
||||
/// also expires every active API key for that user so cookie and
|
||||
/// bearer credentials become inert at the same moment.
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
/// Capability gate: every endpoint here requires
|
||||
/// `InstanceManageUsers` (owner / admin).
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn admins_router(state: AdminsState) -> Router {
|
||||
@@ -113,15 +115,29 @@ pub struct PatchAdminRequest {
|
||||
|
||||
async fn list_admins(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let rows = state.users.list().await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn get_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -133,8 +149,24 @@ async fn get_admin(
|
||||
|
||||
async fn create_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateAdminRequest>,
|
||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Minting an owner via the API requires the caller to ALSO be an
|
||||
// owner — admin cannot self-elevate (or elevate someone else)
|
||||
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||
// bypasses this path.
|
||||
if input.instance_role == InstanceRole::Owner
|
||||
&& principal.instance_role != InstanceRole::Owner
|
||||
{
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
@@ -148,9 +180,16 @@ async fn create_admin(
|
||||
|
||||
async fn patch_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
Json(input): Json<PatchAdminRequest>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Verify the target exists upfront — keeps the error path uniform
|
||||
// for "rename a missing user" etc.
|
||||
let current = state
|
||||
@@ -179,6 +218,12 @@ async fn patch_admin(
|
||||
}
|
||||
|
||||
if let Some(new_role) = input.instance_role {
|
||||
// Self-elevation guard: only an owner can promote anyone TO
|
||||
// owner. An admin cannot turn themselves (or anyone else)
|
||||
// into one.
|
||||
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
// 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
|
||||
@@ -249,8 +294,15 @@ async fn patch_admin(
|
||||
|
||||
async fn delete_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<StatusCode, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let target = state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -328,6 +380,15 @@ pub enum AdminApiError {
|
||||
#[error("cannot leave the system with zero active owners")]
|
||||
LastActiveOwner,
|
||||
|
||||
#[error("only an owner can grant the owner role")]
|
||||
CannotEscalate,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
Hash(String),
|
||||
|
||||
@@ -335,6 +396,15 @@ pub enum AdminApiError {
|
||||
Repo(#[from] AdminUserRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AdminApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
@@ -347,9 +417,18 @@ impl IntoResponse for AdminApiError {
|
||||
| Self::InvalidPassword(_)
|
||||
| Self::LastActiveAdmin
|
||||
| Self::LastActiveOwner
|
||||
| Self::CannotEscalate
|
||||
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "admin_users authz error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user