From 33697a27669dc0ed43bb75b9fcdbac1d8fec32a3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 27 May 2026 21:25:23 +0200 Subject: [PATCH] feat(api): expose caller's effective app role via my_role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/manager-core/src/apps_api.rs | 35 ++++++++++++++++-- crates/picloud/tests/authz.rs | 55 +++++++++++++++++++++++++++++ dashboard/src/lib/api.ts | 7 ++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/manager-core/src/apps_api.rs b/crates/manager-core/src/apps_api.rs index 7204ac6..b63c633 100644 --- a/crates/manager-core/src/apps_api.rs +++ b/crates/manager-core/src/apps_api.rs @@ -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, + /// 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, } // ---------------------------------------------------------------------------- @@ -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, 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, Extension(principal): Extension, @@ -546,6 +571,12 @@ impl From for AppsApiError { } } +impl From 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 { diff --git a/crates/picloud/tests/authz.rs b/crates/picloud/tests/authz.rs index f62f74e..40ba321 100644 --- a/crates/picloud/tests/authz.rs +++ b/crates/picloud/tests/authz.rs @@ -645,3 +645,58 @@ async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) { .expect("count"); assert_eq!(remaining, 1, "one other owner should remain (owner2)"); } + +// ---------------------------------------------------------------------------- +// 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn my_role_field_matches_caller_role(pool: PgPool) { + let s = boot(pool).await; + + // Owner → implicit app_admin everywhere. + let owner_token = login_token(&s.server, "owner", "owner-pw").await; + let r = s + .server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {owner_token}")) + .await; + r.assert_status_ok(); + assert_eq!( + r.json::()["my_role"].as_str(), + Some("app_admin"), + "owner reports app_admin" + ); + + // Admin → implicit editor everywhere. + seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; + let admin_token = login_token(&s.server, "alice", "alice-pw").await; + let r = s + .server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {admin_token}")) + .await; + r.assert_status_ok(); + assert_eq!( + r.json::()["my_role"].as_str(), + Some("editor"), + "admin reports editor" + ); + + // Member with explicit `viewer` membership → viewer. + let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; + grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await; + let bob_token = login_token(&s.server, "bob", "bob-pw").await; + let r = s + .server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {bob_token}")) + .await; + r.assert_status_ok(); + assert_eq!( + r.json::()["my_role"].as_str(), + Some("viewer"), + "member with viewer row reports viewer" + ); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 24046dd..64a1d75 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -44,6 +44,8 @@ export interface App { updated_at: string; } +export type AppRole = 'app_admin' | 'editor' | 'viewer'; + export type DomainShape = 'exact' | 'wildcard' | 'parameterized'; export interface AppDomain { @@ -64,6 +66,11 @@ export interface AppLookupResponse { updated_at: string; /// Present only when the requested slug was a retired redirect. redirect_to?: string; + /// The caller's role on this app — owners are implicit `app_admin`, + /// admins implicit `editor`, members carry their `app_members.role`. + /// `null` only when a member somehow reaches the endpoint without + /// a membership (the server normally 403s first). + my_role: AppRole | null; } export interface SlugCheckResponse {