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::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::{Extension, Router};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use picloud_shared::InstanceRole;
|
|
||||||
|
|
||||||
use crate::admin_session_repo::AdminSessionRepository;
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
use crate::api_key_repo::ApiKeyRepository;
|
use crate::api_key_repo::ApiKeyRepository;
|
||||||
use crate::auth::hash_password;
|
use crate::auth::hash_password;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
|
||||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
/// 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
|
/// also expires every active API key for that user so cookie and
|
||||||
/// bearer credentials become inert at the same moment.
|
/// bearer credentials become inert at the same moment.
|
||||||
pub keys: Arc<dyn ApiKeyRepository>,
|
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 {
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
@@ -113,15 +115,29 @@ pub struct PatchAdminRequest {
|
|||||||
|
|
||||||
async fn list_admins(
|
async fn list_admins(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let rows = state.users.list().await?;
|
let rows = state.users.list().await?;
|
||||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_admin(
|
async fn get_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state
|
state
|
||||||
.users
|
.users
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -133,8 +149,24 @@ async fn get_admin(
|
|||||||
|
|
||||||
async fn create_admin(
|
async fn create_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateAdminRequest>,
|
Json(input): Json<CreateAdminRequest>,
|
||||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
) -> 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();
|
let username = input.username.trim();
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
validate_password(&input.password)?;
|
||||||
@@ -148,9 +180,16 @@ async fn create_admin(
|
|||||||
|
|
||||||
async fn patch_admin(
|
async fn patch_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
Json(input): Json<PatchAdminRequest>,
|
Json(input): Json<PatchAdminRequest>,
|
||||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Verify the target exists upfront — keeps the error path uniform
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
// for "rename a missing user" etc.
|
// for "rename a missing user" etc.
|
||||||
let current = state
|
let current = state
|
||||||
@@ -179,6 +218,12 @@ async fn patch_admin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(new_role) = input.instance_role {
|
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
|
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||||
// leave the install with zero owners. The check is on the
|
// leave the install with zero owners. The check is on the
|
||||||
// source role (current.instance_role) so demoting an
|
// source role (current.instance_role) so demoting an
|
||||||
@@ -249,8 +294,15 @@ async fn patch_admin(
|
|||||||
|
|
||||||
async fn delete_admin(
|
async fn delete_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
) -> Result<StatusCode, AdminApiError> {
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let target = state
|
let target = state
|
||||||
.users
|
.users
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -328,6 +380,15 @@ pub enum AdminApiError {
|
|||||||
#[error("cannot leave the system with zero active owners")]
|
#[error("cannot leave the system with zero active owners")]
|
||||||
LastActiveOwner,
|
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}")]
|
#[error("failed to hash password: {0}")]
|
||||||
Hash(String),
|
Hash(String),
|
||||||
|
|
||||||
@@ -335,6 +396,15 @@ pub enum AdminApiError {
|
|||||||
Repo(#[from] AdminUserRepositoryError),
|
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 {
|
impl IntoResponse for AdminApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
@@ -347,9 +417,18 @@ impl IntoResponse for AdminApiError {
|
|||||||
| Self::InvalidPassword(_)
|
| Self::InvalidPassword(_)
|
||||||
| Self::LastActiveAdmin
|
| Self::LastActiveAdmin
|
||||||
| Self::LastActiveOwner
|
| Self::LastActiveOwner
|
||||||
|
| Self::CannotEscalate
|
||||||
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(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(_)) => {
|
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||||
|
ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,10 @@ pub struct AdminState<R, L> {
|
|||||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
pub apps: Arc<dyn AppRepository>,
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
/// Phase 3.5 capability checks — every script handler resolves
|
||||||
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||||
|
/// loading the resource.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -41,6 +47,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
apps: self.apps.clone(),
|
apps: self.apps.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -129,14 +136,22 @@ where
|
|||||||
|
|
||||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Query(q): Query<ListScriptsQuery>,
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
// Membership filter: `member` users see only scripts in apps they
|
||||||
|
// belong to. `?app=` filters further by app and additionally
|
||||||
|
// requires the member to belong to that app (the read check uses
|
||||||
|
// the resource's app_id).
|
||||||
if let Some(ident) = q.app {
|
if let Some(ident) = q.app {
|
||||||
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
Ok(Json(state.repo.list_for_app(app).await?))
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||||
} else {
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||||
Ok(Json(state.repo.list().await?))
|
|
||||||
}
|
}
|
||||||
|
if principal.instance_role == InstanceRole::Member {
|
||||||
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||||
|
}
|
||||||
|
Ok(Json(state.repo.list().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
@@ -159,20 +174,34 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
|||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
state
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
.repo
|
require(
|
||||||
.get(id)
|
state.authz.as_ref(),
|
||||||
.await?
|
&principal,
|
||||||
.map(Json)
|
Capability::AppRead(script.app_id),
|
||||||
.ok_or(ApiError::NotFound(id))
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateScriptRequest>,
|
Json(input): Json<CreateScriptRequest>,
|
||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
|
// Capability is bound to the *requested* app_id since there's no
|
||||||
|
// resource to load yet. If the app doesn't exist we 422 below;
|
||||||
|
// checking authz first means a Member trying to create against an
|
||||||
|
// unknown app gets 403 (no enumeration of app existence).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
@@ -201,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
Json(input): Json<UpdateScriptRequest>,
|
Json(input): Json<UpdateScriptRequest>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(src) = input.source.as_deref() {
|
if let Some(src) = input.source.as_deref() {
|
||||||
state.validator.validate(src)?;
|
state.validator.validate(src)?;
|
||||||
}
|
}
|
||||||
@@ -229,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -249,9 +294,17 @@ const fn default_limit() -> i64 {
|
|||||||
|
|
||||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppLogRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
// unbounded over time so a paged read is the only sane default.
|
// unbounded over time so a paged read is the only sane default.
|
||||||
let limit = q.limit.clamp(1, 200);
|
let limit = q.limit.clamp(1, 200);
|
||||||
@@ -281,10 +334,25 @@ pub enum ApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Ceiling(#[from] CeilingError),
|
Ceiling(#[from] CeilingError),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
@@ -294,6 +362,14 @@ impl IntoResponse for ApiError {
|
|||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! that writes the history row in the same transaction.
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
@@ -22,7 +22,15 @@ pub struct AppLookup {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppRepository: Send + Sync {
|
pub trait AppRepository: Send + Sync {
|
||||||
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
/// users go through `list_for_user`.
|
||||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug_or_history(
|
async fn get_by_slug_or_history(
|
||||||
@@ -92,6 +100,23 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM apps a \
|
||||||
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ use axum::extract::{Path, Query, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::Router;
|
use axum::{Extension, Router};
|
||||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
use picloud_shared::{App, AppDomain, AppId};
|
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ pub struct AppsState {
|
|||||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
/// operation so the orchestrator sees changes immediately.
|
/// operation so the orchestrator sees changes immediately.
|
||||||
pub domain_table: Arc<AppDomainTable>,
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apps_router(state: AppsState) -> Router {
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
@@ -144,14 +147,27 @@ pub struct AppLookupResponse {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
async fn list_apps(
|
||||||
Ok(Json(s.apps.list().await?))
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
// Member callers see only apps they're a member of; owner/admin
|
||||||
|
// see everything. Filter at the SQL layer (not just in the
|
||||||
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||||
|
let apps = if principal.instance_role == InstanceRole::Member {
|
||||||
|
s.apps.list_for_user(principal.user_id).await?
|
||||||
|
} else {
|
||||||
|
s.apps.list().await?
|
||||||
|
};
|
||||||
|
Ok(Json(apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_app(
|
async fn create_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateAppRequest>,
|
Json(input): Json<CreateAppRequest>,
|
||||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
validate_slug(&input.slug)?;
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
// Historical-slug check before insert: if the slug is in history
|
// Historical-slug check before insert: if the slug is in history
|
||||||
@@ -178,9 +194,16 @@ async fn create_app(
|
|||||||
|
|
||||||
async fn get_app(
|
async fn get_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(lookup.app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let redirect_to = if lookup.redirected {
|
let redirect_to = if lookup.redirected {
|
||||||
Some(lookup.app.slug.clone())
|
Some(lookup.app.slug.clone())
|
||||||
} else {
|
} else {
|
||||||
@@ -194,10 +217,17 @@ async fn get_app(
|
|||||||
|
|
||||||
async fn patch_app(
|
async fn patch_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<PatchAppRequest>,
|
Json(input): Json<PatchAppRequest>,
|
||||||
) -> Result<Json<App>, AppsApiError> {
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(current.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Edits to name/description go first (separate from rename so we
|
// Edits to name/description go first (separate from rename so we
|
||||||
// don't conflate the two errors).
|
// don't conflate the two errors).
|
||||||
@@ -240,10 +270,12 @@ async fn patch_app(
|
|||||||
|
|
||||||
async fn delete_app(
|
async fn delete_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Query(q): Query<DeleteAppQuery>,
|
Query(q): Query<DeleteAppQuery>,
|
||||||
) -> Result<StatusCode, AppsApiError> {
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
if q.force {
|
if q.force {
|
||||||
s.apps.delete_cascade(app.id).await?;
|
s.apps.delete_cascade(app.id).await?;
|
||||||
@@ -262,9 +294,12 @@ async fn delete_app(
|
|||||||
|
|
||||||
async fn slug_check(
|
async fn slug_check(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
Path(_id_or_slug): Path<String>,
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<SlugCheckRequest>,
|
Json(input): Json<SlugCheckRequest>,
|
||||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
match validate_slug(&input.new_slug) {
|
match validate_slug(&input.new_slug) {
|
||||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
return Ok(Json(SlugCheckResponse {
|
return Ok(Json(SlugCheckResponse {
|
||||||
@@ -303,18 +338,27 @@ async fn slug_check(
|
|||||||
|
|
||||||
async fn list_domains(
|
async fn list_domains(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_domain(
|
async fn create_domain(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<CreateDomainRequest>,
|
Json(input): Json<CreateDomainRequest>,
|
||||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
let created = s
|
let created = s
|
||||||
.domains
|
.domains
|
||||||
@@ -331,9 +375,16 @@ async fn create_domain(
|
|||||||
|
|
||||||
async fn delete_domain(
|
async fn delete_domain(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
) -> Result<StatusCode, AppsApiError> {
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
};
|
};
|
||||||
@@ -476,10 +527,25 @@ pub enum AppsApiError {
|
|||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -511,6 +577,14 @@ impl IntoResponse for AppsApiError {
|
|||||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apps authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
tracing::error!(error = %e, "apps api db error");
|
tracing::error!(error = %e, "apps api db error");
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -27,6 +27,14 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script in any app the user is a member of. Drives
|
||||||
|
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||||
|
/// API never returns scripts they shouldn't see — even before the
|
||||||
|
/// per-handler capability check fires.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -117,6 +125,24 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||||
|
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||||
|
FROM scripts s \
|
||||||
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::AppDomainRepository;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ pub struct RouteAdminState<RR, SR> {
|
|||||||
/// declared domain claims.
|
/// declared domain claims.
|
||||||
pub domains: Arc<dyn AppDomainRepository>,
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
@@ -39,6 +42,7 @@ impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
|||||||
scripts: self.scripts.clone(),
|
scripts: self.scripts.clone(),
|
||||||
domains: self.domains.clone(),
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,13 +134,26 @@ pub struct MatchedRoute {
|
|||||||
|
|
||||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
Json(input): Json<CreateRouteRequest>,
|
Json(input): Json<CreateRouteRequest>,
|
||||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||||
@@ -154,6 +171,12 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
let app_id = script.app_id;
|
let app_id = script.app_id;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Validate the route's host is consistent with one of the app's
|
// Validate the route's host is consistent with one of the app's
|
||||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
@@ -196,8 +219,22 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
|
// Resolve the route's app before we delete, so the capability
|
||||||
|
// binds to the actual route's app_id (not a path param).
|
||||||
|
let route = state
|
||||||
|
.routes
|
||||||
|
.get(route_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(route.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
@@ -205,8 +242,18 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
|
// routes:check is read-only — peeking at a hypothetical conflict
|
||||||
|
// is bounded by AppRead on the target app (otherwise members
|
||||||
|
// could probe other apps).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||||
|
|
||||||
@@ -235,8 +282,15 @@ async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||||
let host = parsed.host_str().unwrap_or("").to_string();
|
let host = parsed.host_str().unwrap_or("").to_string();
|
||||||
@@ -415,16 +469,34 @@ pub enum RouteApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
ScriptNotFound(ScriptId),
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("route not found: {0}")]
|
||||||
|
RouteNotFound(Uuid),
|
||||||
|
|
||||||
#[error("host {host:?} is not claimed by this app")]
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
HostNotClaimed {
|
HostNotClaimed {
|
||||||
host: String,
|
host: String,
|
||||||
available_claims: Vec<String>,
|
available_claims: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for RouteApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for RouteApiError {
|
impl IntoResponse for RouteApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -443,10 +515,23 @@ impl IntoResponse for RouteApiError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
Self::ScriptNotFound(_)
|
||||||
|
| Self::RouteNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
Self::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "route authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
serde_json::json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::HostNotClaimed {
|
Self::HostNotClaimed {
|
||||||
host,
|
host,
|
||||||
available_claims,
|
available_claims,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ pub struct NewRoute {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
|
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||||
|
/// the capability check binds to the route's actual `app_id`
|
||||||
|
/// (not a path param).
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
@@ -66,6 +70,18 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(route_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ use picloud_executor_core::{Engine, Limits};
|
|||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||||
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||||
AppDomainRepository, AppRepository, AppsState, AuthState, PostgresAdminSessionRepository,
|
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
|
||||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
|
||||||
RouteRepository, SandboxCeiling,
|
RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -88,7 +88,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
Arc::new(PostgresAppDomainRepository::new(pool));
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
|
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
||||||
|
// per-handler capability checks introduced in Phase 3.5.
|
||||||
|
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
@@ -123,6 +126,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
@@ -131,6 +135,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
domains: domains_repo.clone(),
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
@@ -144,6 +149,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
domains: domains_repo,
|
domains: domains_repo,
|
||||||
routes: route_repo,
|
routes: route_repo,
|
||||||
domain_table: app_domain_table,
|
domain_table: app_domain_table,
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_state = AuthState {
|
let auth_state = AuthState {
|
||||||
@@ -156,6 +162,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
users: auth.users,
|
users: auth.users,
|
||||||
sessions: auth.sessions,
|
sessions: auth.sessions,
|
||||||
keys: auth.keys.clone(),
|
keys: auth.keys.clone(),
|
||||||
|
authz,
|
||||||
};
|
};
|
||||||
let api_keys_state = ApiKeysState {
|
let api_keys_state = ApiKeysState {
|
||||||
keys: auth.keys,
|
keys: auth.keys,
|
||||||
@@ -258,6 +265,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list_for_app(app_id).await
|
self.0.list_for_app(app_id).await
|
||||||
}
|
}
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: picloud_shared::AdminUserId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_user(user_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
Reference in New Issue
Block a user