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:
@@ -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 {
|
||||
|
||||
@@ -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::<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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user