feat(dashboard): capabilities helper for role-aware UI shadowing
Pure-function module that mirrors crates/manager-core/src/authz.rs and lets dashboard pages decide which create / edit / delete affordances to render. Widens the vitest include so the truth-table test runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
60
dashboard/src/lib/capabilities.test.ts
Normal file
60
dashboard/src/lib/capabilities.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { AppRole, MeDto } from './api';
|
||||
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
|
||||
|
||||
function me(role: MeDto['instance_role']): MeDto {
|
||||
return { id: 'u', username: 'u', instance_role: role, email: null };
|
||||
}
|
||||
|
||||
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
|
||||
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
|
||||
|
||||
describe('capabilities', () => {
|
||||
it('null caller is denied everything', () => {
|
||||
expect(canCreateApp(null)).toBe(false);
|
||||
expect(canManageUsers(null)).toBe(false);
|
||||
expect(canWriteApp(null, 'app_admin')).toBe(false);
|
||||
expect(canAdminApp(null, 'app_admin')).toBe(false);
|
||||
});
|
||||
|
||||
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
|
||||
expect(canCreateApp(me('owner'))).toBe(true);
|
||||
expect(canCreateApp(me('admin'))).toBe(true);
|
||||
expect(canCreateApp(me('member'))).toBe(false);
|
||||
expect(canManageUsers(me('owner'))).toBe(true);
|
||||
expect(canManageUsers(me('admin'))).toBe(true);
|
||||
expect(canManageUsers(me('member'))).toBe(false);
|
||||
});
|
||||
|
||||
it('owner + admin can write and admin every app regardless of my_role', () => {
|
||||
for (const role of ['owner', 'admin'] as const) {
|
||||
for (const appRole of APP_ROLES) {
|
||||
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||
expect(canAdminApp(me(role), appRole)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('member: write requires app_admin or editor; admin requires app_admin', () => {
|
||||
const m = me('member');
|
||||
expect(canWriteApp(m, 'app_admin')).toBe(true);
|
||||
expect(canWriteApp(m, 'editor')).toBe(true);
|
||||
expect(canWriteApp(m, 'viewer')).toBe(false);
|
||||
expect(canWriteApp(m, null)).toBe(false);
|
||||
|
||||
expect(canAdminApp(m, 'app_admin')).toBe(true);
|
||||
expect(canAdminApp(m, 'editor')).toBe(false);
|
||||
expect(canAdminApp(m, 'viewer')).toBe(false);
|
||||
expect(canAdminApp(m, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('canAdminApp implies canWriteApp for every combination', () => {
|
||||
for (const role of ROLES) {
|
||||
for (const appRole of APP_ROLES) {
|
||||
if (canAdminApp(me(role), appRole)) {
|
||||
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
43
dashboard/src/lib/capabilities.ts
Normal file
43
dashboard/src/lib/capabilities.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Permission predicates the dashboard uses to shadow create / edit /
|
||||
// delete affordances. Mirrors the canonical role → capability rules in
|
||||
// crates/manager-core/src/authz.rs:
|
||||
//
|
||||
// owner / admin instance role → implicit app_admin on every app
|
||||
// app_admin → settings, domain claims, delete app, delete scripts
|
||||
// editor → CRUD on scripts, routes, sandbox config (no script delete)
|
||||
// viewer → read scripts + execution logs
|
||||
// member with no membership → no access
|
||||
//
|
||||
// These helpers are read-only and have no Svelte runes — callers pass
|
||||
// the current `MeDto` and (when relevant) the per-app `my_role` they
|
||||
// already hold. Hiding here never authorizes anything; the backend's
|
||||
// `require(Capability::…)` is always the ground truth.
|
||||
|
||||
import type { AppRole, MeDto } from './api';
|
||||
|
||||
/** Owner + admin only. Members never see "New app". */
|
||||
export function canCreateApp(me: MeDto | null): boolean {
|
||||
if (!me) return false;
|
||||
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||
}
|
||||
|
||||
/** Owner + admin only — the "Users" admin page is also gated this way. */
|
||||
export function canManageUsers(me: MeDto | null): boolean {
|
||||
if (!me) return false;
|
||||
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||
}
|
||||
|
||||
/** Can mutate scripts and routes (Save, +Add route, remove route). */
|
||||
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||
if (!me) return false;
|
||||
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||
return appMyRole === 'app_admin' || appMyRole === 'editor';
|
||||
}
|
||||
|
||||
/** Can take app-admin actions: app settings, domain claims, delete
|
||||
* app, delete scripts, manage members. */
|
||||
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||
if (!me) return false;
|
||||
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||
return appMyRole === 'app_admin';
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/lib/rhai/**/*.test.ts'],
|
||||
include: ['src/lib/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user