diff --git a/src/api.ts b/src/api.ts index c681518..982bb98 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,18 @@ const BASE = '' * Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件 * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } +export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean } +export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number } +export interface RoleRecord { + roleId: string; name: string; description: string; enabled: boolean; roleVersion: number + bindingCount: number; permissions: RolePermissionRecord[] +} +export interface RoleListPage { roles: RoleRecord[]; nextCursor?: string } +export interface UserRoleBinding { + bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number + assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number +} +export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord } async function checked(res: Response): Promise { if (!res.ok) { @@ -29,6 +41,35 @@ async function json(res: Response): Promise { } export const api = { + permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []), + listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => { + const params = new URLSearchParams() + if (request.query) params.set('query', request.query) + if (request.mode) params.set('mode', request.mode) + if (request.status) params.set('status', request.status) + if (request.action) params.set('action', request.action) + if (request.cursor) params.set('cursor', request.cursor) + if (request.limit) params.set('limit', String(request.limit)) + const query = params.toString() + return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json(r)) + }, + createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }), + }).then(r => json(r)), + patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) => + auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json(r)), + replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) => + auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)), + getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json(r)), + assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) => + auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json(r)), + getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)), + replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) => + auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)), listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts new file mode 100644 index 0000000..c764a17 --- /dev/null +++ b/src/rolePermissions.ts @@ -0,0 +1,56 @@ +export type RolePermissionValue = { allowed: boolean; capAllowed: boolean } +export type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean } + +export interface RolePermissionDraft { + baseline: Record + values: Record + generation: number +} + +function copy(values: Record): Record { + return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }])) +} + +export function createRolePermissionDraft(values: Record, generation = 0): RolePermissionDraft { + return { baseline: copy(values), values: copy(values), generation } +} + +export function updateRolePermission( + draft: RolePermissionDraft, + actionId: string, + value: RolePermissionValue, +): RolePermissionDraft { + return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 } +} + +export function mergeRoleDraft( + current: RolePermissionDraft, + serverValues: Record, + responseGeneration: number, +): RolePermissionDraft { + if (responseGeneration < current.generation) return current + return createRolePermissionDraft(serverValues, responseGeneration) +} + +export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] { + const seen = new Set() + const changes: RolePermissionChange[] = [] + for (const actionId of visibleActionIds) { + if (seen.has(actionId)) continue + seen.add(actionId) + const value = draft.values[actionId] + if (!value) continue + const baseline = draft.baseline[actionId] + if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue + changes.push({ actionId, ...value }) + } + return changes +} + +export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null { + for (const actionId of Object.keys(draft.values).sort()) { + const value = draft.values[actionId] + if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' } + } + return null +} diff --git a/test/role-permissions.test.ts b/test/role-permissions.test.ts new file mode 100644 index 0000000..64f2701 --- /dev/null +++ b/test/role-permissions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + createRolePermissionDraft, mergeRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch, + type RolePermissionValue, +} from '../src/rolePermissions' + +const initial: Record = { + 'project.create': { allowed: false, capAllowed: true }, + 'document.read': { allowed: true, capAllowed: true }, + 'document.author': { allowed: false, capAllowed: true }, +} + +describe('role permission draft', () => { + it('retains hidden edits and patches only explicit visible actions', () => { + let draft = createRolePermissionDraft(initial) + draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true }) + draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true }) + + expect(visiblePatch(draft, ['project.create'])).toEqual([ + { actionId: 'project.create', allowed: true, capAllowed: true }, + ]) + expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true }) + }) + + it('produces the same patch for single-role and matrix edits', () => { + const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true }) + const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true }) + expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial))) + }) + + it('discards a stale server refresh without replacing newer edits', () => { + const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true }) + const merged = mergeRoleDraft(edited, initial, edited.generation - 1) + expect(merged).toBe(edited) + }) + + it('rejects defaults that exceed the role cap', () => { + const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false }) + expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' }) + }) +})