feat(api): expose caller's effective app role via my_role

GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).

The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 21:25:23 +02:00
parent 6eb32a78bf
commit 33697a2766
3 changed files with 95 additions and 2 deletions

View File

@@ -17,14 +17,14 @@ use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
@@ -141,6 +141,12 @@ pub struct AppLookupResponse {
/// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
/// per blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
}
// ----------------------------------------------------------------------------
@@ -209,12 +215,31 @@ async fn get_app(
} else {
None
};
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
Ok(Json(AppLookupResponse {
app: lookup.app,
redirect_to,
my_role,
}))
}
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
/// everywhere; `Member` consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
}
}
async fn patch_app(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
@@ -546,6 +571,12 @@ impl From<AuthzDenied> for AppsApiError {
}
}
impl From<AuthzError> for AppsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {