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::routing::{delete, get, post};
use axum::{Extension, 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, InstanceRole, Principal}; use picloud_shared::{App, AppDomain, AppId, AppRole, 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::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository; use crate::route_repo::RouteRepository;
@@ -141,6 +141,12 @@ pub struct AppLookupResponse {
/// at the live slug so dashboards can redirect. /// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>, 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 { } else {
None None
}; };
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
Ok(Json(AppLookupResponse { Ok(Json(AppLookupResponse {
app: lookup.app, app: lookup.app,
redirect_to, 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( async fn patch_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>, 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 { impl IntoResponse for AppsApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {

View File

@@ -645,3 +645,58 @@ async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
.expect("count"); .expect("count");
assert_eq!(remaining, 1, "one other owner should remain (owner2)"); 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::<Value>()["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::<Value>()["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::<Value>()["my_role"].as_str(),
Some("viewer"),
"member with viewer row reports viewer"
);
}

View File

@@ -44,6 +44,8 @@ export interface App {
updated_at: string; updated_at: string;
} }
export type AppRole = 'app_admin' | 'editor' | 'viewer';
export type DomainShape = 'exact' | 'wildcard' | 'parameterized'; export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain { export interface AppDomain {
@@ -64,6 +66,11 @@ export interface AppLookupResponse {
updated_at: string; updated_at: string;
/// Present only when the requested slug was a retired redirect. /// Present only when the requested slug was a retired redirect.
redirect_to?: string; 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 { export interface SlugCheckResponse {