From a1bc8e5cfa13414907edbc995e8f01a14240717e Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 04:16:46 -0400 Subject: [PATCH 1/7] feat(roles): edit a role's permissions in a visual editor, with JSON as the fallback Add and Edit Role now open on a visual editor: role type, structure user (none / all databases / selected databases), the existing operations allowlist editor, a per-database table grid for read/insert/update/delete, and per-table attribute scoping. "Edit as JSON" switches to the Monaco view of the same draft, and the visual view comes back only while the JSON holds a document it can represent; otherwise the JSON view lists why. The JSON text stays the single source of truth. Every visual edit is a copy-on-write patch of the keys it touches, so content the GUI does not model is carried through unchanged and listed as kept. The editor no longer seeds the JSON from calculateDefaultPermissions: its global Pick Attributes flag rewrote every table's attribute list at once, and expanding a list wrote the primary key as an explicit false row, which revokes the key Harper otherwise derives from the other attributes. Add Role creates the role with its full permission in one step, through preparePermissionForSave. That also fixes creating a super user role: the old form always sent structure_user beside super_user, which Harper's validateNoSUPerms rejects. preparePermissionForSave now drops structure_user beside super_user/cluster_user for the same reason. Refs #1634 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 + src/features/instance/config/roles/DESIGN.md | 49 ++ .../config/roles/defaultCalculator.test.ts | 94 --- .../config/roles/defaultCalculator.ts | 166 ----- .../roles/editor/RolePermissionEditor.tsx | 185 +++++ .../roles/editor/RoleVisualEditor.test.tsx | 148 ++++ .../config/roles/editor/RoleVisualEditor.tsx | 700 ++++++++++++++++++ .../roles/editor/rolePermissionModel.test.ts | 333 +++++++++ .../roles/editor/rolePermissionModel.ts | 629 ++++++++++++++++ .../roles/editor/useRolePermissionDraft.ts | 32 + src/features/instance/config/roles/index.tsx | 10 +- .../config/roles/modals/AddRoleModal.test.tsx | 99 +++ .../config/roles/modals/AddRoleModal.tsx | 138 ++-- .../roles/modals/EditRoleModal.test.tsx | 343 ++++++--- .../config/roles/modals/EditRoleModal.tsx | 279 ++----- .../roles/preparePermissionForSave.test.ts | 9 + .../config/roles/preparePermissionForSave.ts | 15 +- src/integrations/api/instance/auth/addRole.ts | 18 +- .../api/instance/auth/addRoleFormSchema.ts | 2 - 19 files changed, 2566 insertions(+), 685 deletions(-) create mode 100644 src/features/instance/config/roles/DESIGN.md delete mode 100644 src/features/instance/config/roles/defaultCalculator.test.ts delete mode 100644 src/features/instance/config/roles/defaultCalculator.ts create mode 100644 src/features/instance/config/roles/editor/RolePermissionEditor.tsx create mode 100644 src/features/instance/config/roles/editor/RoleVisualEditor.test.tsx create mode 100644 src/features/instance/config/roles/editor/RoleVisualEditor.tsx create mode 100644 src/features/instance/config/roles/editor/rolePermissionModel.test.ts create mode 100644 src/features/instance/config/roles/editor/rolePermissionModel.ts create mode 100644 src/features/instance/config/roles/editor/useRolePermissionDraft.ts create mode 100644 src/features/instance/config/roles/modals/AddRoleModal.test.tsx diff --git a/DESIGN.md b/DESIGN.md index 3bc9a4b1f..728ea85f1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,6 +12,8 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, and why a table without a primary key can't be listed. +- [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. + - [`docs/status-redesign.md`](docs/status-redesign.md) — port of analytics-viz visualizations + spec system into the instance Status tab. Covers: 7-tab IA, the data-path adapter (no SQL — `get_analytics` only), bucket-by-window clamps, chart-surface CSS tokens, feature-flag rollback, testing strategy. Read before touching `src/features/instance/status/**`. ## API explorer (the instance/cluster "APIs" tab) diff --git a/src/features/instance/config/roles/DESIGN.md b/src/features/instance/config/roles/DESIGN.md new file mode 100644 index 000000000..1ed6863af --- /dev/null +++ b/src/features/instance/config/roles/DESIGN.md @@ -0,0 +1,49 @@ +# Instance roles — design notes + +## The role editor's JSON text is the only source of truth + +Both role modals edit a draft whose text is the role's `permission` JSON +([`useRolePermissionDraft`](editor/useRolePermissionDraft.ts)). The visual editor +([`RoleVisualEditor`](editor/RoleVisualEditor.tsx)) is a lens over that text, not a model of its +own. It reads the parsed document, and every edit is a copy-on-write patch from +[`rolePermissionModel`](editor/rolePermissionModel.ts) that changes only the keys it touches. A key +the GUI does not model therefore survives a round-trip without the GUI knowing it exists. Examples +are a database record's `describe`, which Harper's permission translator honours, and an attribute +entry's `delete`, which role validation tolerates. + +Never serialize the role from a schema-derived template or from a typed model. The editor used to +seed its JSON from `calculateDefaultPermissions`, and that template rewrote what it did not model. +Its global "Pick Attributes" flag expanded or emptied every table's attribute list at once. When it +expanded one, it wrote the primary key as an explicit `false` row, and that revokes the key Harper +otherwise derives from the other attributes. Tables the role doesn't mention are left out of the +JSON. The GUI renders them as no access, which is how Harper reads an absent table, and creates an +entry only when one is first granted. + +Two kinds of content the GUI does not render are handled differently: + +- `getVisualEditorBlockers` lists shapes the GUI could only render by guessing. Examples are a + non-boolean CRUD flag, a non-array `attribute_permissions`, and an unusable `operations` value. + While the list is non-empty, the JSON view stays in charge and shows the list. Most of these are + shapes Harper's role validation rejects anyway. +- `getPreservedEntries` lists content that is well-formed but not shown, and the GUI carries it + through unchanged. Databases and tables missing from describe_all are listed separately. Harper + rejects a role that names them (`SCHEMA_NOT_FOUND` / `TABLE_NOT_FOUND`), so the GUI offers to + remove them, but it never removes them on its own. An attribute entry missing from describe_all is + only "not shown", because describe_all omits `@computed` attributes unless asked. + +Save is the one place that changes the document on purpose. `preparePermissionForSave` writes a +`null` `attribute_permissions` as `[]` (HarperFast/harper#3143). It also removes what Harper refuses +beside `super_user` / `cluster_user`, which is everything, `structure_user` included. For +`structure_user: true` it removes the per-table permissions, which no longer apply. Switching a role +type in the GUI keeps everything else in the draft until then, so switching back within a session +loses nothing. + +A visual edit keeps the object it produced instead of re-parsing its own text. That keeps object +identity for every untouched database and table, so the memoized `DatabaseSection` / `TableRows` +re-render only what changed, which matters on a schema with hundreds of tables. + +The Harper semantics the editor shows are written down where they are encoded. The effective +attribute access, including the derived primary key and timestamps being read-only, is in +`getAttributeViews`. What `structure_user` grants is in `StructureUserSection`'s copy. Each was +traced to Harper 5.3.1 source and checked against its `role_validation` / `permissionsTranslator` +before it reached UI copy. Re-check those before changing the copy. diff --git a/src/features/instance/config/roles/defaultCalculator.test.ts b/src/features/instance/config/roles/defaultCalculator.test.ts deleted file mode 100644 index cc88b1c77..000000000 --- a/src/features/instance/config/roles/defaultCalculator.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { calculateDefaultPermissions } from '@/features/instance/config/roles/defaultCalculator'; -import { InstanceDatabaseMap, LocalRolePermission } from '@/integrations/api/api.patch'; -import { orderPermissionKeys, withOperations } from '@/integrations/api/localRolePermission'; -import { describe, expect, it } from 'vitest'; - -// JSON.parse, not an object literal: a literal `__proto__` key sets the prototype instead of -// creating an own property, so the fixture itself has to come from parsed JSON. -const databaseMap = (json: string) => JSON.parse(json) as InstanceDatabaseMap; -const oneTable = '{"dog":{"attributes":[{"attribute":"id"}]}}'; - -describe('calculateDefaultPermissions', () => { - it('keeps a database named __proto__ as an own, serializable key', () => { - // Plain assignment would hit the prototype setter: the database would vanish from the - // template and from JSON.stringify, so the role could never be granted it. - const result = calculateDefaultPermissions({ - instanceDatabaseMap: databaseMap(`{"__proto__":{"dog":{"attributes":[{"attribute":"id"}]}}}`), - currentRolePermissions: {}, - version: '5.2.2', - showAttributes: false, - }); - - expect(Object.hasOwn(result, '__proto__')).toBe(true); - // Read the own property explicitly: `parsed.__proto__` would resolve the prototype instead. - const serialized = JSON.parse(JSON.stringify(result)) as object; - const record = Object.getOwnPropertyDescriptor(serialized, '__proto__')?.value as - | { tables: Record } - | undefined; - expect(record?.tables).toHaveProperty('dog'); - // The round trip the editor actually performs must preserve it too. - const roundTripped = JSON.parse(JSON.stringify(withOperations(orderPermissionKeys(result), ['sql']))); - expect(Object.hasOwn(roundTripped, '__proto__')).toBe(true); - }); - - it('skips a database named like a reserved key when the instance reserves it', () => { - const map = databaseMap(`{"operations":{"dog":{"attributes":[{"attribute":"id"}]}}}`); - // 5.2 reserves `operations` for the allowlist, so writing the database there would clobber it. - expect( - calculateDefaultPermissions({ - instanceDatabaseMap: map, - currentRolePermissions: { operations: ['sql'] }, - version: '5.2.2', - showAttributes: false, - }).operations, - ).toEqual(['sql']); - - // Below the allowlist floor the same name is just a database, so it gets a permission record. - const legacy = calculateDefaultPermissions({ - instanceDatabaseMap: map, - currentRolePermissions: {}, - version: '4.7.3', - showAttributes: false, - }).operations as unknown as { tables: Record }; - expect(legacy.tables).toHaveProperty('dog'); - }); - - it('preserves an existing allowlist while rebuilding table permissions', () => { - const permission: LocalRolePermission = { operations: ['read_only'] }; - const result = calculateDefaultPermissions({ - instanceDatabaseMap: databaseMap(`{"data":${oneTable}}`), - currentRolePermissions: permission, - version: '5.2.2', - showAttributes: false, - }); - expect(result.operations).toEqual(['read_only']); - expect(result.data).toBeDefined(); - }); - - it('writes an empty attribute_permissions array when attributes are not picked, never null', () => { - // HarperFast/harper#3143: a stored `null` makes every `['*']` read the role sends 500. - const fresh = calculateDefaultPermissions({ - instanceDatabaseMap: databaseMap(`{"data":${oneTable}}`), - currentRolePermissions: { super_user: false, structure_user: false }, - version: '5.3.1', - showAttributes: false, - }); - expect(fresh.data).toEqual({ - tables: { dog: { read: false, insert: false, update: false, delete: false, attribute_permissions: [] } }, - }); - - const stored = calculateDefaultPermissions({ - instanceDatabaseMap: databaseMap(`{"data":${oneTable}}`), - currentRolePermissions: { - data: { - tables: { dog: { read: true, insert: false, update: false, delete: false, attribute_permissions: null } }, - }, - }, - version: '4.7.40', - showAttributes: false, - }); - expect(stored.data).toEqual({ - tables: { dog: { read: true, insert: false, update: false, delete: false, attribute_permissions: [] } }, - }); - }); -}); diff --git a/src/features/instance/config/roles/defaultCalculator.ts b/src/features/instance/config/roles/defaultCalculator.ts deleted file mode 100644 index 8fa18950f..000000000 --- a/src/features/instance/config/roles/defaultCalculator.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { supportsOperationsAllowlist } from '@/features/instance/config/roles/operations/operationsCatalog'; -import { - InstanceDatabaseMap, - LocalLegacyRolePermissionTable, - LocalRolePermission, - LocalRolePermissionTable, - LocalRoleSchemaRecord, -} from '@/integrations/api/api.patch'; -import { getDatabasePermissionRecord, RESERVED_PERMISSION_KEYS } from '@/integrations/api/localRolePermission'; -import { keyBy } from '@/lib/keyBy'; - -export function calculateDefaultPermissions({ - instanceDatabaseMap, - currentRolePermissions, - version, - showAttributes, -}: { - instanceDatabaseMap: InstanceDatabaseMap; - currentRolePermissions: LocalRolePermission; - version: string; - showAttributes: boolean; -}): LocalRolePermission { - const permissionStructure: LocalRolePermission = { - ...currentRolePermissions, - }; - if ( - currentRolePermissions.super_user || currentRolePermissions.structure_user || currentRolePermissions.cluster_user - ) { - return currentRolePermissions; - } - const [major, minor, patch] = version.split('.').map(number => parseInt(number, 10)); - const legacy = version !== '2.0.000' && major <= 2 && minor <= 1 && patch <= 2; - - // Plain assignment of a `__proto__` key would hit the prototype setter rather than create an own - // property, so the database would vanish from the template. Harper permits the name, so define - // the property instead of dropping the database. - const setDatabase = (name: string, record: LocalRoleSchemaRecord) => { - Object.defineProperty(permissionStructure, name, { - value: record, - writable: true, - enumerable: true, - configurable: true, - }); - return record; - }; - - const allowlistSupported = supportsOperationsAllowlist(version); - - for (const databaseName in instanceDatabaseMap) { - if (RESERVED_PERMISSION_KEYS.has(databaseName) && (databaseName !== 'operations' || allowlistSupported)) { - // A database named like a reserved permission key cannot be expressed in role JSON — - // writing it here would clobber the reserved value (e.g. a 5.0+ operations allowlist). - // Pre-5.0 Harper has no reserved `operations`, so there a database by that name is real. - continue; - } - const databaseRecord = setDatabase(databaseName, { tables: {} }); - const extantDatabasePermissions = currentRolePermissions - && getDatabasePermissionRecord(currentRolePermissions, databaseName, allowlistSupported); - for (const tableName in instanceDatabaseMap[databaseName]) { - const thisTable = instanceDatabaseMap[databaseName][tableName]; - const attributes = thisTable.attributes.map((a) => a.attribute).sort(); - const extantTablePermissions = extantDatabasePermissions - && extantDatabasePermissions.tables?.[tableName]; - if (legacy) { - databaseRecord.tables[tableName] = buildLegacy( - extantTablePermissions as LocalLegacyRolePermissionTable, - attributes, - showAttributes, - ); - } else { - databaseRecord.tables[tableName] = buildCurrent( - extantTablePermissions as LocalRolePermissionTable, - attributes, - showAttributes, - ); - } - } - } - - return permissionStructure; -} - -function buildLegacy( - extantTablePermissions: LocalLegacyRolePermissionTable | undefined, - attributes: string[], - showAttributes: boolean, -): LocalLegacyRolePermissionTable { - const attributeRestrictionsMap = extantTablePermissions - && keyBy(extantTablePermissions.attribute_restrictions, 'attribute_name'); - return { - read: extantTablePermissions ? extantTablePermissions.read : true, - insert: extantTablePermissions ? extantTablePermissions.insert : true, - update: extantTablePermissions ? extantTablePermissions.update : true, - delete: extantTablePermissions ? extantTablePermissions.delete : true, - attribute_restrictions: attributes - .filter(() => showAttributes) - .map((a) => { - const extantAttributePermissions = attributeRestrictionsMap?.[a]; - - return { - attribute_name: a, - read: extantAttributePermissions - ? extantAttributePermissions.read - : extantTablePermissions - ? extantTablePermissions.read - : true, - insert: extantAttributePermissions - ? extantAttributePermissions.insert - : extantTablePermissions - ? extantTablePermissions.insert - : true, - update: extantAttributePermissions - ? extantAttributePermissions.update - : extantTablePermissions - ? extantTablePermissions.update - : true, - delete: extantAttributePermissions - ? extantAttributePermissions.delete - : extantTablePermissions - ? extantTablePermissions.delete - : true, - }; - }), - }; -} - -function buildCurrent( - extantTablePermissions: LocalRolePermissionTable | undefined, - attributes: string[], - showAttributes: boolean, -): LocalRolePermissionTable { - const attributePermissionsMap = extantTablePermissions - && keyBy(extantTablePermissions.attribute_permissions || [], 'attribute_name'); - return { - read: extantTablePermissions ? extantTablePermissions.read : false, - insert: extantTablePermissions ? extantTablePermissions.insert : false, - update: extantTablePermissions ? extantTablePermissions.update : false, - delete: extantTablePermissions ? extantTablePermissions.delete : false, - attribute_permissions: showAttributes - ? attributes - .map((a: string) => { - const extantAttributePermissions = attributePermissionsMap?.[a]; - - return { - attribute_name: a, - read: extantAttributePermissions - ? extantAttributePermissions.read - : extantTablePermissions?.attribute_permissions?.length - ? false - : extantTablePermissions?.read || false, - insert: extantAttributePermissions - ? extantAttributePermissions.insert - : extantTablePermissions?.attribute_permissions?.length - ? false - : extantTablePermissions?.insert || false, - update: extantAttributePermissions - ? extantAttributePermissions.update - : extantTablePermissions?.attribute_permissions?.length - ? false - : extantTablePermissions?.update || false, - }; - }) - // Not `null`, which Harper stores and then 500s on for every `['*']` read (HarperFast/harper#3143). - : [], - }; -} diff --git a/src/features/instance/config/roles/editor/RolePermissionEditor.tsx b/src/features/instance/config/roles/editor/RolePermissionEditor.tsx new file mode 100644 index 000000000..513802c22 --- /dev/null +++ b/src/features/instance/config/roles/editor/RolePermissionEditor.tsx @@ -0,0 +1,185 @@ +import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton'; +import { Button } from '@/components/ui/button'; +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { getVisualEditorBlockers } from '@/features/instance/config/roles/editor/rolePermissionModel'; +import { RoleVisualEditor } from '@/features/instance/config/roles/editor/RoleVisualEditor'; +import { RolePermissionDraft } from '@/features/instance/config/roles/editor/useRolePermissionDraft'; +import { supportsOperationsAllowlist } from '@/features/instance/config/roles/operations/operationsCatalog'; +import { OperationsValueNotice } from '@/features/instance/config/roles/operations/OperationsValueNotice'; +import { useMonacoTheme } from '@/hooks/useMonacoTheme'; +import { InstanceDatabaseMap, LocalRolePermission } from '@/integrations/api/api.patch'; +import { getDescribeAllQueryOptions } from '@/integrations/api/instance/database/getDescribeAll'; +import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; +import { classifyOperationsValue } from '@/integrations/api/localRolePermission'; +import { Editor } from '@/lib/monaco/MonacoEditor'; +import { useQuery } from '@tanstack/react-query'; +import { CodeIcon, RefreshCwIcon, Table2Icon } from 'lucide-react'; +import { useDeferredValue, useMemo, useState } from 'react'; + +export interface RoleEditorSchema { + instanceDatabaseMap?: InstanceDatabaseMap; + version?: string; + isLoading: boolean; + isError: boolean; + retry: () => void; +} + +/** The instance schema and version both role modals render against. */ +export function useRoleEditorSchema(): RoleEditorSchema { + const instanceParams = useInstanceClientIdParams(); + // Permission editing only needs the schema tree, never record counts, so this shares the + // count-free describe_all cache entry the Databases tab populates. + const describeAll = useQuery(getDescribeAllQueryOptions({ ...instanceParams, skipRecordCount: true })); + const registrationInfo = useQuery(getRegistrationInfoQueryOptions(instanceParams)); + return { + instanceDatabaseMap: describeAll.data, + version: registrationInfo.data?.version, + isLoading: describeAll.isPending || registrationInfo.isPending, + // A failed background refetch of data already in hand is not a reason to lose the visual editor. + isError: (describeAll.isError && !describeAll.data) || (registrationInfo.isError && !registrationInfo.data), + retry: () => { + void describeAll.refetch(); + void registrationInfo.refetch(); + }, + }; +} + +/** + * Saving past an unusable `operations` value would normalize it away and widen the role, so it has + * to be repaired in the JSON first. Without a version the allowlist floor is unknown, and Harper's + * own role validation is left to reject a malformed value. + */ +export function isOperationsValueUnusable(permission: LocalRolePermission | null, version: string | undefined) { + if (!permission || !version) { + return false; + } + const kind = classifyOperationsValue(permission, supportsOperationsAllowlist(version)); + return kind === 'breaks-auth' || kind === 'malformed'; +} + +const MAX_LISTED_BLOCKERS = 5; + +/** + * Visual-first role permission editor with a JSON escape hatch (HarperFast/studio#1634), mirroring + * the schema editor's toggle. The draft's text is the single source of truth: the visual editor + * patches the parsed document and writes the result back, and the JSON view edits the text + * directly, so switching views never loses an edit. The visual view is offered only while the text + * holds a document it can represent; otherwise the JSON view says why and stays in charge. + */ +export function RolePermissionEditor({ + draft, + schema, + readOnly, +}: { + draft: RolePermissionDraft; + schema: RoleEditorSchema; + readOnly?: boolean; +}) { + const { text, permission } = draft; + const monacoTheme = useMonacoTheme(); + const { instanceDatabaseMap, version } = schema; + const schemaReady = !!instanceDatabaseMap && !!version; + // Undecided until the schema loads; then the view opens on the visual editor when it can. + const [preferJson, setPreferJson] = useState(); + // The JSON view re-checks on every keystroke; deferring keeps typing responsive on a large role. + const deferredPermission = useDeferredValue(permission); + const analysed = preferJson === true ? deferredPermission : permission; + const blockers = useMemo(() => { + if (!instanceDatabaseMap || !version) { + return []; + } + return analysed + ? getVisualEditorBlockers(analysed, instanceDatabaseMap, version) + : ['The JSON is not a valid object.']; + }, [analysed, instanceDatabaseMap, version]); + const visualAvailable = schemaReady && !!analysed && blockers.length === 0; + if (preferJson === undefined && schemaReady) { + setPreferJson(!visualAvailable); + } + + const operationsKind = permission && version + ? classifyOperationsValue(permission, supportsOperationsAllowlist(version)) + : 'absent'; + const operations = permission?.operations; + + if (schema.isLoading && !schema.isError) { + return ; + } + + if (preferJson === false && visualAvailable && permission && instanceDatabaseMap && version) { + return ( +
+
+ +
+
+ +
+
+ ); + } + + return ( +
+
+
+ {schema.isError + ? ( + + The visual editor needs this instance's schema and version, which didn't load. + + + ) + : blockers.length > 0 && ( + <> +

The visual editor can't show this role until the JSON is fixed:

+
    + {blockers.slice(0, MAX_LISTED_BLOCKERS).map((blocker) =>
  • {blocker}
  • )} + {blockers.length > MAX_LISTED_BLOCKERS &&
  • …and {blockers.length - MAX_LISTED_BLOCKERS} more.
  • } +
+ + )} +
+ {schemaReady && ( + + )} +
+ {(operationsKind === 'breaks-auth' || operationsKind === 'malformed') && ( + + )} +
+ draft.setText(value ?? '')} + /> +
+
+ ); +} diff --git a/src/features/instance/config/roles/editor/RoleVisualEditor.test.tsx b/src/features/instance/config/roles/editor/RoleVisualEditor.test.tsx new file mode 100644 index 000000000..5fc050c7f --- /dev/null +++ b/src/features/instance/config/roles/editor/RoleVisualEditor.test.tsx @@ -0,0 +1,148 @@ +/** + * @vitest-environment jsdom + */ +import { getTableEntry } from '@/features/instance/config/roles/editor/rolePermissionModel'; +import { RoleVisualEditor } from '@/features/instance/config/roles/editor/RoleVisualEditor'; +import { InstanceDatabaseMap, LocalRolePermission } from '@/integrations/api/api.patch'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +const schema = { + data: { + dog: { + primary_key: 'id', + attributes: [{ attribute: 'id' }, { attribute: 'name' }, { attribute: '__createdtime__' }], + }, + cat: { primary_key: 'id', attributes: [{ attribute: 'id' }] }, + }, + dev: { thing: { primary_key: 'id', attributes: [{ attribute: 'id' }] } }, +} as unknown as InstanceDatabaseMap; + +beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; + if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; + } +}); + +afterEach(cleanup); + +/** Holds the document like the modal does, and exposes the latest one to the test. */ +function renderEditor(initial: unknown, version = '5.3.1') { + const latest = { current: initial as LocalRolePermission }; + function Harness() { + const [permission, setPermission] = useState(latest.current); + return ( + { + latest.current = next; + setPermission(next); + }} + instanceDatabaseMap={schema} + version={version} + /> + ); + } + render(); + return latest; +} + +const checkbox = (name: string) => screen.getByRole('checkbox', { name }) as HTMLInputElement; +const table = (read: boolean, attributes: unknown = [], rest: object = {}) => ({ + read, + insert: false, + update: false, + delete: false, + attribute_permissions: attributes, + ...rest, +}); + +describe('RoleVisualEditor', () => { + it('shows a database-wide action as mixed and sets it on every table at once', () => { + const latest = renderEditor({ data: { tables: { dog: table(true) } } }); + + const allRead = checkbox('Read on every table in data'); + expect(allRead.checked).toBe(false); + expect(allRead.indeterminate).toBe(true); + fireEvent.click(allRead); + expect(latest.current).toEqual({ data: { tables: { dog: table(true), cat: table(true) } } }); + fireEvent.click(checkbox('Read on every table in data')); + expect(latest.current).toEqual({ data: { tables: { dog: table(false), cat: table(false) } } }); + }); + + it("shows a scoped table's effective attribute access, including a derived primary key", () => { + renderEditor({ + data: { + tables: { + dog: table(true, [ + { attribute_name: 'name', read: true, insert: false, update: false }, + { attribute_name: '__createdtime__', read: false, insert: true, update: true }, + ]), + }, + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Attribute permissions for data.dog' })); + expect(checkbox('Read on data.dog.id').checked).toBe(true); + expect(screen.getByText(/isn't listed, so it gets whatever access any other attribute has/)).toBeTruthy(); + // A timestamp only ever gets read; its stored insert does not show as granted. + expect(checkbox('Insert on data.dog.__createdtime__').checked).toBe(false); + expect(checkbox('Insert on data.dog.__createdtime__').disabled).toBe(true); + // The attributes-column summary counts attributes with any access. + expect(screen.getByRole('button', { name: 'Attribute permissions for data.dog' }).textContent).toContain('2 of 3'); + }); + + it('grants the table action when an attribute is given it, and restores a list switched off and on', () => { + const latest = renderEditor({ data: { tables: { dog: table(true) } } }); + + fireEvent.click(screen.getByRole('button', { name: 'Attribute permissions for data.dog' })); + fireEvent.click(screen.getByRole('switch', { name: 'Limit data.dog to specific attributes' })); + fireEvent.click(checkbox('Update on data.dog.name')); + const dog = () => getTableEntry(latest.current, 'data', 'dog'); + expect(dog()?.update).toBe(true); + const scoped = dog()?.attribute_permissions; + + fireEvent.click(screen.getByRole('switch', { name: 'Limit data.dog to specific attributes' })); + expect(dog()?.attribute_permissions).toEqual([]); + fireEvent.click(screen.getByRole('switch', { name: 'Limit data.dog to specific attributes' })); + expect(dog()?.attribute_permissions).toEqual(scoped); + }); + + it('does not restore attribute grants the table no longer has', () => { + const latest = renderEditor({ data: { tables: { dog: table(true) } } }); + + fireEvent.click(screen.getByRole('button', { name: 'Attribute permissions for data.dog' })); + const limit = () => screen.getByRole('switch', { name: 'Limit data.dog to specific attributes' }); + fireEvent.click(limit()); + fireEvent.click(limit()); + fireEvent.click(checkbox('Read on data.dog')); + fireEvent.click(limit()); + // Role validation rejects an attribute granting what its table does not (MISMATCHED_TABLE_ATTR_PERMS). + const attributes = getTableEntry(latest.current, 'data', 'dog')?.attribute_permissions ?? []; + expect(attributes.length).toBeGreaterThan(0); + expect(attributes.every((attribute) => attribute.read === false)).toBe(true); + }); + + it('scopes structure_user to selected databases and hides their table grid', () => { + const latest = renderEditor({ data: { tables: {} } }); + + fireEvent.click(screen.getByRole('radio', { name: /Selected databases/ })); + fireEvent.click(checkbox('Structure user on data')); + expect(latest.current.structure_user).toEqual(['data']); + expect(screen.getByText('full access (structure user)')).toBeTruthy(); + expect(screen.queryByRole('checkbox', { name: 'Read on data.dog' })).toBeNull(); + }); + + it('offers the cluster user type on Harper 4, and to a role that already has it', () => { + renderEditor({}, '4.7.40'); + expect(screen.getByRole('radio', { name: /Cluster user/ })).toBeTruthy(); + cleanup(); + renderEditor({ cluster_user: true }); + expect(screen.getByRole('radio', { name: /Cluster user/ }).getAttribute('aria-checked')).toBe('true'); + }); +}); diff --git a/src/features/instance/config/roles/editor/RoleVisualEditor.tsx b/src/features/instance/config/roles/editor/RoleVisualEditor.tsx new file mode 100644 index 000000000..b9cafb16c --- /dev/null +++ b/src/features/instance/config/roles/editor/RoleVisualEditor.tsx @@ -0,0 +1,700 @@ +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radioGroup'; +import { Switch } from '@/components/ui/switch'; +import { + ATTRIBUTE_ACTIONS, + getAttributeViews, + getDatabaseRecord, + getEditableDatabaseNames, + getPreservedEntries, + getRoleType, + getTableEntry, + getTableEntryInRecord, + getTablePrimaryKey, + isTableScopedToAttributes, + removeEntriesNotInSchema, + RoleType, + setAttributeAction, + setAttributeScoping, + setDatabaseAction, + setRoleType, + setStructureUser, + setTableAction, + TABLE_ACTIONS, +} from '@/features/instance/config/roles/editor/rolePermissionModel'; +import { OperationsAllowlistEditor } from '@/features/instance/config/roles/operations/OperationsAllowlistEditor'; +import { supportsOperationsAllowlist } from '@/features/instance/config/roles/operations/operationsCatalog'; +import { + InstanceDatabaseMap, + InstanceDatabaseTableMap, + InstanceTable, + LocalRoleAttributePermissionTable, + LocalRolePermission, + LocalRolePermissionTable, +} from '@/integrations/api/api.patch'; +import { + getOperationsAllowlist, + RESERVED_PERMISSION_KEYS, + rolePreventsOperationsAllowlist, + structureUserDdlScope, + withOperations, +} from '@/integrations/api/localRolePermission'; +import { cn } from '@/lib/cn'; +import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB'; +import { ChevronDownIcon, ChevronRightIcon, TriangleAlertIcon } from 'lucide-react'; +import { + type ComponentProps, + Fragment, + memo, + useCallback, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +const ACTION_LABELS = { read: 'Read', insert: 'Insert', update: 'Update', delete: 'Delete' } as const; + +/** + * The GUI half of the role editor (HarperFast/studio#1634): renders a parsed permission document + * against the instance schema and reports every edit as the whole next document. Rendering what + * Harper would enforce, not just what is stored: a table the role does not mention shows as no + * access, and a scoped table shows each attribute's effective access. + */ +export function RoleVisualEditor({ + permission, + onChange, + instanceDatabaseMap, + version, + readOnly, +}: { + permission: LocalRolePermission; + onChange: (next: LocalRolePermission) => void; + instanceDatabaseMap: InstanceDatabaseMap; + version: string; + readOnly?: boolean; +}) { + const roleType = getRoleType(permission); + const structureUser = permission.structure_user; + const operationsSupported = supportsOperationsAllowlist(version); + const databaseNames = useMemo( + () => getEditableDatabaseNames(instanceDatabaseMap, version), + [instanceDatabaseMap, version], + ); + const preserved = useMemo( + () => getPreservedEntries(permission, instanceDatabaseMap, version), + [permission, instanceDatabaseMap, version], + ); + // Keyed on the serialized form so edits elsewhere keep the array identity, and with it the + // picker's own state, stable. + const operationsJson = JSON.stringify(getOperationsAllowlist(permission) ?? null); + const operationsValue = useMemo(() => JSON.parse(operationsJson) as string[] | null ?? undefined, [operationsJson]); + + return ( +
+ {preserved.notInSchema.length > 0 && ( + + + This role names things this instance's schema doesn't have + +

+ {preserved.notInSchema.join(', ')}. Harper refuses to save a role that names a database or table that + doesn't exist, so remove them before saving. +

+ {!readOnly && ( + + )} +
+
+ )} + + + + {roleType === 'standard' && ( + + )} + + {operationsSupported && ( +
+

Operations

+ onChange(withOperations(permission, next))} + version={version} + allowlistRejected={rolePreventsOperationsAllowlist(permission)} + structureUserDdl={structureUserDdlScope(permission)} + disabled={readOnly} + /> +
+ )} + + {roleType === 'standard' && structureUser !== true && ( + + )} + + {preserved.notShown.length > 0 && ( +

+ Also kept as they are, but not shown here:{' '} + {preserved.notShown.join(', ')}. Use “Edit as JSON” to see or change them. +

+ )} +
+ ); +} + +/** Whether saving will drop anything beside the role's own flag (see preparePermissionForSave). */ +function carriesOtherPermissions(permission: LocalRolePermission, flag: 'super_user' | 'cluster_user'): boolean { + return Object.keys(permission).some((key) => key !== flag && permission[key] !== false); +} + +function RoleTypeSection({ + permission, + roleType, + version, + readOnly, + onChange, +}: { + permission: LocalRolePermission; + roleType: RoleType; + version: string; + readOnly?: boolean; + onChange: (next: LocalRolePermission) => void; +}) { + const id = useId(); + // Only Harper 4 gives this flag a meaning (it marks the role of the user that connects cluster + // nodes); nothing in Harper 5 reads it, so it is offered there only to a role that already has it. + const offerClusterUser = roleType === 'cluster_user' || !wasAReleasedBeforeB('5.0.0', version); + const dropsOthers = roleType !== 'standard' && carriesOtherPermissions(permission, roleType); + return ( +
+

Role type

+ onChange(setRoleType(permission, value as RoleType))} + disabled={readOnly} + aria-labelledby={`${id}-heading`} + > + + Access comes from the database and table permissions below. + + + Bypasses per-table permissions. + + {offerClusterUser && ( + + Harper 4 uses this role type for the user that connects cluster nodes. + + )} + + {dropsOthers && ( +

+ Saving keeps only the {roleType} flag: Harper rejects a{' '} + {roleType === 'super_user' ? 'super user' : 'cluster user'}{' '} + role that carries any other permission, so this role's other settings are removed. +

+ )} +
+ ); +} + +function RadioOption({ id, value, label, children }: { id: string; value: string; label: string; children: string }) { + return ( +
+ + +
+ ); +} + +function StructureUserSection({ + permission, + databaseNames, + readOnly, + onChange, +}: { + permission: LocalRolePermission; + databaseNames: string[]; + readOnly?: boolean; + onChange: (next: LocalRolePermission) => void; +}) { + const id = useId(); + const structureUser = permission.structure_user; + const mode = structureUser === true ? 'all' : Array.isArray(structureUser) ? 'selected' : 'off'; + const selected = Array.isArray(structureUser) ? structureUser : []; + // Lets "Selected databases" → another mode → back restore the picks instead of starting empty. + const lastSelectionRef = useRef(selected); + if (Array.isArray(structureUser)) { + lastSelectionRef.current = structureUser; + } + const listed = [...databaseNames, ...selected.filter((name) => !databaseNames.includes(name))]; + const hasTablePermissions = Object.keys(permission).some((key) => !RESERVED_PERMISSION_KEYS.has(key)); + + const onModeChange = (next: string) => { + onChange( + setStructureUser(permission, next === 'all' ? true : next === 'selected' ? lastSelectionRef.current : false), + ); + }; + const toggleDatabase = (name: string) => { + onChange(setStructureUser( + permission, + selected.includes(name) ? selected.filter((entry) => entry !== name) : [...selected, name], + )); + }; + + return ( +
+

Structure user

+ + + Creating and dropping databases, tables and attributes needs super user, or an operations list that grants it. + + + Can create and drop databases, tables and attributes, and can read, insert, update and delete in every table. + + + Can create and drop tables and attributes in the selected databases, and can read, insert, update and delete + in their tables. Creating and dropping databases isn't included. + + + {mode !== 'off' && permission.operations !== undefined && ( +

The operations list below can still deny some of these.

+ )} + {mode === 'all' && hasTablePermissions && ( +

+ Per-table permissions don't apply to this role, so they are removed when you save. +

+ )} + {mode === 'selected' && ( +
+ {listed.length === 0 &&

This instance has no databases.

} + {listed.map((name) => ( + + ))} +
+ )} +
+ ); +} + +type PermissionPatch = (permission: LocalRolePermission) => LocalRolePermission; + +function TablePermissionsSection({ + permission, + instanceDatabaseMap, + databaseNames, + structureDatabases, + readOnly, + onChange, +}: { + permission: LocalRolePermission; + instanceDatabaseMap: InstanceDatabaseMap; + databaseNames: string[]; + structureDatabases: string[]; + readOnly?: boolean; + onChange: (next: LocalRolePermission) => void; +}) { + // Databases the role already grants something on open expanded, so the role reads at a glance; + // a role with no grants yet opens the first one as a starting point. + const [openDatabases, setOpenDatabases] = useState(() => { + const granted = databaseNames.filter((databaseName) => + Object.keys(instanceDatabaseMap[databaseName]).some((tableName) => { + const entry = getTableEntry(permission, databaseName, tableName); + return entry && TABLE_ACTIONS.some((action) => entry[action] === true); + }) + ); + return granted.length ? granted : databaseNames.slice(0, 1); + }); + // Survives turning attribute scoping off and on again within the session, like the operations + // switch does for its allowlist. + const scopingRestoreRef = useRef(new Map()); + // One stable updater for every row, applied to the latest document: rows are memoized on their + // own database record, so on a large schema an edit re-renders only the database it touched. + const latestRef = useRef({ permission, onChange }); + latestRef.current = { permission, onChange }; + const update = useCallback((patch: PermissionPatch) => { + latestRef.current.onChange(patch(latestRef.current.permission)); + }, []); + + return ( +
+

Table permissions

+

+ A table without a checked permission has no access. Delete always applies to whole records. +

+ {databaseNames.length === 0 &&

This instance has no databases.

} + + {databaseNames.map((databaseName) => ( + + ))} + +
+ ); +} + +const DatabaseSection = memo(function DatabaseSection({ + databaseName, + tables, + record, + structureScoped, + readOnly, + update, + scopingRestore, +}: { + databaseName: string; + tables: InstanceDatabaseTableMap; + record: Record | undefined; + structureScoped: boolean; + readOnly?: boolean; + update: (patch: PermissionPatch) => void; + scopingRestore: Map; +}) { + const tableNames = Object.keys(tables); + const entries = tableNames.map((tableName) => getTableEntryInRecord(record, tableName)); + const grantedCount = entries.filter((entry) => entry && TABLE_ACTIONS.some((action) => entry[action] === true)) + .length; + + return ( + + + + {databaseName} + + {structureScoped + ? 'full access (structure user)' + : `${grantedCount} of ${tableNames.length} ${tableNames.length === 1 ? 'table' : 'tables'} with access`} + + + + + {structureScoped + ? ( +

+ As a structure user on this database, the role can read, insert, update and delete in every table here, so + per-table permissions don't apply to it. +

+ ) + : tableNames.length === 0 + ?

No tables.

+ : ( +
+ + + + + {TABLE_ACTIONS.map((action) => ( + + ))} + + + + + + + {TABLE_ACTIONS.map((action) => { + const count = entries.filter((entry) => entry?.[action] === true).length; + return ( + + ); + })} + + {tableNames.map((tableName, index) => ( + + ))} + +
Table{ACTION_LABELS[action]}Attributes
All tables + 0 && count < tableNames.length} + disabled={readOnly} + aria-label={`${ACTION_LABELS[action]} on every table in ${databaseName}`} + onChange={() => + update((permission) => + setDatabaseAction( + permission, + databaseName, + tableNames, + action, + count < tableNames.length, + ) + )} + /> + +
+
+ )} +
+
+ ); +}); + +const TableRows = memo(function TableRows({ + databaseName, + tableName, + schemaTable, + entry, + readOnly, + update, + scopingRestore, +}: { + databaseName: string; + tableName: string; + schemaTable: InstanceTable; + entry: LocalRolePermissionTable | undefined; + readOnly?: boolean; + update: (patch: PermissionPatch) => void; + scopingRestore: Map; +}) { + const attributeNames = useMemo( + () => schemaTable.attributes.map((attribute) => attribute.attribute), + [schemaTable], + ); + const primaryKey = getTablePrimaryKey(schemaTable); + const scoped = isTableScopedToAttributes(entry); + const [expanded, setExpanded] = useState(false); + const label = `${databaseName}.${tableName}`; + const views = scoped && entry ? getAttributeViews(entry, attributeNames, primaryKey) : []; + const accessible = views.filter((view) => ATTRIBUTE_ACTIONS.some((action) => view[action])).length; + const restoreKey = `${databaseName}\u0000${tableName}`; + + const onScopingChanged = (next: boolean) => { + if (!next && entry?.attribute_permissions) { + scopingRestore.set(restoreKey, entry.attribute_permissions); + } + update((permission) => + setAttributeScoping( + permission, + databaseName, + tableName, + next, + attributeNames, + next ? scopingRestore.get(restoreKey) : undefined, + ) + ); + }; + + return ( + + + {tableName} + {TABLE_ACTIONS.map((action) => ( + + { + const { checked } = event.target; + update((permission) => setTableAction(permission, databaseName, tableName, action, checked)); + }} + /> + + ))} + + + + + {expanded && ( + + + + update((permission) => + setAttributeAction( + permission, + databaseName, + tableName, + attributeNames, + primaryKey, + attributeName, + action, + value, + ) + )} + /> + + + )} + + ); +}); + +function AttributePanel({ + label, + scoped, + views, + disabled, + onScopingChanged, + onActionChanged, +}: { + label: string; + scoped: boolean; + views: ReturnType; + disabled?: boolean; + onScopingChanged: (scoped: boolean) => void; + onActionChanged: (attributeName: string, action: (typeof ATTRIBUTE_ACTIONS)[number], value: boolean) => void; +}) { + const id = useId(); + const derivedKey = views.find((view) => view.derived); + return ( +
+
+ + +
+

+ {scoped + ? "Only the access checked here applies. An attribute with nothing checked has no access, including attributes added to the table later. Computed attributes aren't listed here; grant them in the JSON." + : "The table's read, insert and update apply to every attribute."} +

+ {scoped && ( + + + + + {ATTRIBUTE_ACTIONS.map((action) => ( + + ))} + + + + {views.map((view) => ( + + + {ATTRIBUTE_ACTIONS.map((action) => { + const timestampOnlyRead = view.isTimestamp && action !== 'read'; + return ( + + ); + })} + + ))} + +
Attribute{ACTION_LABELS[action]}
+ {view.name} + {view.isPrimaryKey && (primary key)} + + onActionChanged(view.name, action, event.target.checked)} + /> +
+ )} + {scoped && derivedKey && ( +

+ The primary key {derivedKey.name}{' '} + isn't listed, so it gets whatever access any other attribute has. Changing it here sets it explicitly. +

+ )} + {scoped && views.some((view) => view.isTimestamp) && ( +

Harper applies only read to timestamp attributes.

+ )} +
+ ); +} + +/** `indeterminate` is a DOM property with no attribute behind it, so it has to be set on the node. */ +function PermissionCheckbox({ + indeterminate = false, + className, + ...props +}: ComponentProps<'input'> & { indeterminate?: boolean }) { + const ref = useRef(null); + useLayoutEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate; + } + }, [indeterminate]); + return ( + + ); +} diff --git a/src/features/instance/config/roles/editor/rolePermissionModel.test.ts b/src/features/instance/config/roles/editor/rolePermissionModel.test.ts new file mode 100644 index 000000000..5ffd42690 --- /dev/null +++ b/src/features/instance/config/roles/editor/rolePermissionModel.test.ts @@ -0,0 +1,333 @@ +import { + getAttributeViews, + getEditableDatabaseNames, + getPreservedEntries, + getRoleType, + getTableEntry, + getVisualEditorBlockers, + isTableScopedToAttributes, + parseRolePermissionText, + removeEntriesNotInSchema, + serializeRolePermission, + setAttributeAction, + setAttributeScoping, + setDatabaseAction, + setRoleType, + setStructureUser, + setTableAction, +} from '@/features/instance/config/roles/editor/rolePermissionModel'; +import { InstanceDatabaseMap, InstanceTable, LocalRolePermission } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; + +function table(name: string, attributes: string[], primaryKey = 'id'): InstanceTable { + return { + schema: 'data', + name, + primary_key: primaryKey, + audit: false, + schema_defined: true, + db_size: 0, + sources: [], + attributes: attributes.map((attribute) => ({ attribute, is_primary_key: attribute === primaryKey })), + } as unknown as InstanceTable; +} + +const schema: InstanceDatabaseMap = { + data: { + dog: table('dog', ['id', 'name', 'breed', '__createdtime__']), + cat: table('cat', ['id', 'name']), + }, + dev: { thing: table('thing', ['id']) }, +}; +const VERSION = '5.3.1'; +const dogAttributes = ['id', 'name', 'breed', '__createdtime__']; + +const entry = (read: boolean, insert = false, update = false, del = false, attributes: unknown = []) => ({ + read, + insert, + update, + delete: del, + attribute_permissions: attributes, +}); + +const asPermission = (value: unknown) => value as LocalRolePermission; + +describe('parseRolePermissionText', () => { + it('returns only JSON objects', () => { + expect(parseRolePermissionText('{"super_user":true}')).toEqual({ super_user: true }); + expect(parseRolePermissionText('[]')).toBeNull(); + expect(parseRolePermissionText('null')).toBeNull(); + expect(parseRolePermissionText('{"super_user":')).toBeNull(); + expect(parseRolePermissionText('')).toBeNull(); + }); +}); + +describe('getEditableDatabaseNames', () => { + it('skips a database named operations once the key is the allowlist', () => { + const withOperations = { ...schema, operations: { t: table('t', ['id']) } }; + expect(getEditableDatabaseNames(withOperations, '5.3.1')).toEqual(['data', 'dev']); + expect(getEditableDatabaseNames(withOperations, '4.7.40')).toEqual(['data', 'dev', 'operations']); + }); +}); + +describe('getVisualEditorBlockers', () => { + it('accepts the shapes Harper stores, including null attribute_permissions', () => { + const permission = asPermission({ + super_user: false, + structure_user: ['dev'], + operations: ['read_only'], + data: { tables: { dog: entry(true, false, false, false, null), cat: entry(true) } }, + }); + expect(getVisualEditorBlockers(permission, schema, VERSION)).toEqual([]); + }); + + it('names every shape the GUI cannot represent', () => { + const permission = asPermission({ + super_user: 'yes', + structure_user: { dev: true }, + operations: { tables: {} }, + data: { + tables: { + dog: { read: true, insert: false, update: false, attribute_permissions: 'all' }, + cat: entry(true, false, false, false, [{ attribute_name: 'name', read: true }, { read: true }]), + }, + }, + dev: 'everything', + }); + const blockers = getVisualEditorBlockers(permission, schema, VERSION); + expect(blockers).toEqual([ + '"super_user" must be true or false.', + '"structure_user" must be true, false, or a list of database names.', + '"operations" is not a list of operation names.', + '"data.dog": delete must each be true or false.', + '"data.dog.attribute_permissions" must be a list.', + '"data.cat.attribute_permissions": "name" needs read, insert and update set to true or false.', + '"data.cat.attribute_permissions" has an entry without an attribute_name.', + 'The entry for database "dev" is not an object.', + ]); + }); + + it('refuses both exclusive flags, duplicate attributes and the pre-2.2 format', () => { + expect(getVisualEditorBlockers({ super_user: true, cluster_user: true }, schema, VERSION)).toEqual([ + '"super_user" and "cluster_user" cannot both be true.', + ]); + const duplicated = asPermission({ + data: { + tables: { + cat: entry(true, false, false, false, [ + { attribute_name: 'name', read: true, insert: false, update: false }, + { attribute_name: 'name', read: false, insert: false, update: false }, + ]), + }, + }, + }); + expect(getVisualEditorBlockers(duplicated, schema, VERSION)).toEqual([ + '"data.cat.attribute_permissions" lists "name" more than once.', + ]); + const legacy = asPermission({ data: { tables: { cat: { ...entry(true), attribute_restrictions: [] } } } }); + expect(getVisualEditorBlockers(legacy, schema, VERSION)).toHaveLength(1); + expect(getVisualEditorBlockers({}, schema, '2.1.2')).toHaveLength(1); + // An unparseable version is not evidence of a pre-2.2 instance. + expect(getVisualEditorBlockers({}, schema, 'unknown')).toEqual([]); + }); + + it('ignores the shape of entries the GUI does not render', () => { + const permission = asPermission({ gone: 42, data: { tables: { removed: 'x' } } }); + expect(getVisualEditorBlockers(permission, schema, VERSION)).toEqual([]); + }); +}); + +describe('getPreservedEntries / removeEntriesNotInSchema', () => { + const permission = asPermission({ + structure_user: ['dev', 'gone'], + gone: { tables: { x: entry(true) } }, + data: { + describe: true, + tables: { + dog: { + ...entry(true, false, false, false, [ + { attribute_name: 'name', read: true, insert: false, update: false, delete: true }, + { attribute_name: 'computed_score', read: true, insert: false, update: false }, + ]), + extra: 1, + }, + removed: entry(true), + }, + }, + }); + + it('separates what Harper would reject from what is merely not shown', () => { + expect(getPreservedEntries(permission, schema, VERSION)).toEqual({ + notInSchema: ['structure_user database "gone"', 'database "gone"', 'table "data.removed"'], + notShown: [ + '"data.describe"', + '"data.dog.extra"', + '"delete" on attribute "data.dog.name"', + 'attribute "data.dog.computed_score"', + ], + }); + }); + + it('removes only the entries not in the schema and keeps the rest verbatim', () => { + const cleaned = removeEntriesNotInSchema(permission, schema, VERSION); + expect(cleaned).toEqual({ + structure_user: ['dev'], + data: { describe: true, tables: { dog: (permission.data as { tables: { dog: unknown } }).tables.dog } }, + }); + expect(getPreservedEntries(cleaned, schema, VERSION).notInSchema).toEqual([]); + }); + + it('reports an inert operations value below the allowlist floor without removing it', () => { + const old = asPermission({ operations: ['read_only'] }); + expect(getPreservedEntries(old, schema, '4.7.40').notShown).toEqual([ + '"operations" (has no effect before Harper 5.0)', + ]); + expect(removeEntriesNotInSchema(old, schema, '4.7.40')).toEqual(old); + }); +}); + +describe('role type and structure_user', () => { + it('sets the exclusive flag and keeps the rest of the document for the session', () => { + const permission = asPermission({ super_user: false, data: { tables: { cat: entry(true) } } }); + const superUser = setRoleType(permission, 'super_user'); + expect(superUser).toEqual({ super_user: true, data: permission.data }); + expect(getRoleType(superUser)).toBe('super_user'); + + const back = setRoleType(superUser, 'standard'); + expect(back).toEqual(permission); + // A flag the document never carried is not added just to say false. + expect(setRoleType({}, 'standard')).toEqual({}); + expect(setRoleType({ super_user: true }, 'cluster_user')).toEqual({ super_user: false, cluster_user: true }); + }); + + it('writes structure_user to the top and does not add a false one', () => { + const permission = asPermission({ data: { tables: {} } }); + expect(Object.keys(setStructureUser(permission, ['dev']))).toEqual(['structure_user', 'data']); + expect(setStructureUser(permission, false)).toBe(permission); + expect(setStructureUser({ structure_user: true }, false)).toEqual({ structure_user: false }); + }); +}); + +describe('table actions', () => { + it('creates the entry Harper requires when granting on an unlisted table, and is a no-op when revoking one', () => { + const granted = setTableAction({}, 'data', 'cat', 'read', true); + expect(granted).toEqual({ data: { tables: { cat: entry(true) } } }); + expect(setTableAction({}, 'data', 'cat', 'read', false)).toEqual({}); + }); + + it('preserves every key it does not touch', () => { + const permission = asPermission({ + data: { describe: true, tables: { cat: { ...entry(true, false, false, false, null), extra: 1 } } }, + }); + expect(setTableAction(permission, 'data', 'cat', 'insert', true)).toEqual({ + data: { describe: true, tables: { cat: { ...entry(true, true, false, false, null), extra: 1 } } }, + }); + }); + + it('revokes an action on the attributes too, which role validation requires', () => { + const scoped = asPermission({ + data: { + tables: { + dog: entry(true, true, false, false, [ + { attribute_name: 'name', read: true, insert: true, update: false }, + { attribute_name: 'breed', read: false, insert: true, update: false }, + ]), + }, + }, + }); + const next = setTableAction(scoped, 'data', 'dog', 'insert', false); + expect(getTableEntry(next, 'data', 'dog')?.attribute_permissions).toEqual([ + { attribute_name: 'name', read: true, insert: false, update: false }, + { attribute_name: 'breed', read: false, insert: false, update: false }, + ]); + }); + + it('sets a database-wide action in one pass, keeping table order', () => { + const permission = asPermission({ data: { tables: { cat: entry(false, true) } } }); + const next = setDatabaseAction(permission, 'data', ['dog', 'cat'], 'read', true); + expect(next).toEqual({ data: { tables: { cat: entry(true, true), dog: entry(true) } } }); + expect(Object.keys((next.data as { tables: object }).tables)).toEqual(['cat', 'dog']); + expect(setDatabaseAction({}, 'data', ['dog', 'cat'], 'read', false)).toEqual({}); + }); + + it('keeps a __proto__ database and table as own keys', () => { + const next = setTableAction({}, '__proto__', '__proto__', 'read', true); + const roundTripped = JSON.parse(JSON.stringify(next)) as object; + const database = Object.getOwnPropertyDescriptor(roundTripped, '__proto__')?.value as { tables: object }; + expect(Object.getOwnPropertyDescriptor(database.tables, '__proto__')?.value).toEqual(entry(true)); + expect(getTableEntry(next, '__proto__', '__proto__')).toEqual(entry(true)); + }); +}); + +describe('attribute scoping', () => { + it('lists every attribute with the table access when turned on, so nothing changes at that moment', () => { + const permission = asPermission({ data: { tables: { dog: entry(true, true) } } }); + const scoped = setAttributeScoping(permission, 'data', 'dog', true, dogAttributes); + const dog = getTableEntry(scoped, 'data', 'dog'); + expect(isTableScopedToAttributes(dog)).toBe(true); + expect(dog?.attribute_permissions).toEqual([ + { attribute_name: 'id', read: true, insert: true, update: false }, + { attribute_name: 'name', read: true, insert: true, update: false }, + { attribute_name: 'breed', read: true, insert: true, update: false }, + // Harper only applies read to a timestamp attribute. + { attribute_name: '__createdtime__', read: true, insert: false, update: false }, + ]); + const off = setAttributeScoping(scoped, 'data', 'dog', false, dogAttributes); + expect(getTableEntry(off, 'data', 'dog')).toEqual(entry(true, true)); + const restored = setAttributeScoping(off, 'data', 'dog', true, dogAttributes, dog?.attribute_permissions ?? []); + expect(restored).toEqual(scoped); + }); + + it('derives an unlisted primary key from the listed attributes, as permissionsTranslator does', () => { + const dog = entry(true, true, false, false, [ + { attribute_name: 'name', read: true, insert: false, update: false }, + { attribute_name: '__createdtime__', read: true, insert: true, update: true }, + ]); + const views = getAttributeViews(dog as never, dogAttributes, 'id'); + expect(views.map(({ name, read, insert, update, derived }) => ({ name, read, insert, update, derived }))).toEqual([ + { name: 'id', read: true, insert: false, update: false, derived: true }, + { name: 'name', read: true, insert: false, update: false, derived: false }, + // Not listed in a scoped table: no access. + { name: 'breed', read: false, insert: false, update: false, derived: false }, + // Its stored insert/update are ignored by Harper, so they do not reach the key either. + { name: '__createdtime__', read: true, insert: false, update: false, derived: false }, + ]); + }); + + it('adds an unlisted attribute with its effective access before flipping one action', () => { + const permission = asPermission({ + data: { + tables: { + dog: entry(true, false, false, false, [ + { attribute_name: 'name', read: true, insert: false, update: false }, + ]), + }, + }, + }); + // The derived key keeps read when it becomes explicit, so revoking nothing else by accident. + const pkInsert = setAttributeAction(permission, 'data', 'dog', dogAttributes, 'id', 'id', 'insert', true); + const dog = getTableEntry(pkInsert, 'data', 'dog'); + expect(dog?.attribute_permissions).toEqual([ + { attribute_name: 'name', read: true, insert: false, update: false }, + { attribute_name: 'id', read: true, insert: true, update: false }, + ]); + // Granting an attribute an action its table lacks grants it on the table too. + expect(dog?.insert).toBe(true); + + const revoked = setAttributeAction(pkInsert, 'data', 'dog', dogAttributes, 'id', 'name', 'read', false); + expect(getTableEntry(revoked, 'data', 'dog')?.attribute_permissions?.[0]).toEqual({ + attribute_name: 'name', + read: false, + insert: false, + update: false, + }); + expect(getTableEntry(revoked, 'data', 'dog')?.read).toBe(true); + }); +}); + +describe('serializeRolePermission', () => { + it('floats the role flags above the databases', () => { + const text = serializeRolePermission(asPermission({ data: { tables: {} }, operations: [], super_user: false })); + expect(Object.keys(JSON.parse(text) as object)).toEqual(['super_user', 'operations', 'data']); + }); +}); diff --git a/src/features/instance/config/roles/editor/rolePermissionModel.ts b/src/features/instance/config/roles/editor/rolePermissionModel.ts new file mode 100644 index 000000000..c7dfbefa4 --- /dev/null +++ b/src/features/instance/config/roles/editor/rolePermissionModel.ts @@ -0,0 +1,629 @@ +import { supportsOperationsAllowlist } from '@/features/instance/config/roles/operations/operationsCatalog'; +import { + InstanceDatabaseMap, + InstanceTable, + LocalRoleAttributePermissionAction, + LocalRoleAttributePermissionTable, + LocalRolePermission, + LocalRolePermissionAction, + LocalRolePermissionTable, +} from '@/integrations/api/api.patch'; +import { + classifyOperationsValue, + getDatabasePermissionRecord, + orderPermissionKeys, + RESERVED_PERMISSION_KEYS, +} from '@/integrations/api/localRolePermission'; +import { compareVersions } from '@/lib/string/wasAReleasedBeforeB'; + +/** + * The model behind the visual role editor (HarperFast/studio#1634). The permission JSON stays the + * single source of truth: the editor reads the parsed document and every edit here is a + * copy-on-write patch of only the keys it touches, so a key the GUI does not model survives a + * round-trip by construction rather than by being re-serialized from a model that knows about it. + * + * The rules encoded below were traced to Harper 5.3.1 (`validation/role_validation.ts`, + * `security/permissionsTranslator.js`) and checked against its build. + */ + +export const TABLE_ACTIONS: readonly LocalRolePermissionAction[] = ['read', 'insert', 'update', 'delete']; +export const ATTRIBUTE_ACTIONS: readonly LocalRoleAttributePermissionAction[] = ['read', 'insert', 'update']; + +/** permissionsTranslator applies only `read` to these, whatever the entry says. */ +const TIMESTAMP_ATTRIBUTES: ReadonlySet = new Set(['__createdtime__', '__updatedtime__']); +const TABLE_ENTRY_KEYS: ReadonlySet = new Set([...TABLE_ACTIONS, 'attribute_permissions']); +const ATTRIBUTE_ENTRY_KEYS: ReadonlySet = new Set(['attribute_name', ...ATTRIBUTE_ACTIONS]); + +export type RoleType = 'standard' | 'super_user' | 'cluster_user'; + +type PlainRecord = Record; + +function isPlainRecord(value: unknown): value is PlainRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +// Every key below may be a database, table or attribute name, and Harper permits `__proto__` for +// all three — so reads use hasOwn and writes rebuild with Object.fromEntries, never assignment. +function ownValue(record: PlainRecord, key: string): unknown { + return Object.hasOwn(record, key) ? record[key] : undefined; +} + +function withKey(record: T, key: string, value: unknown): T { + let replaced = false; + const entries = Object.entries(record).map(([existingKey, existing]): [string, unknown] => { + if (existingKey === key) { + replaced = true; + return [existingKey, value]; + } + return [existingKey, existing]; + }); + if (!replaced) { + entries.push([key, value]); + } + return Object.fromEntries(entries) as T; +} + +function withoutKeys(record: T, keys: ReadonlySet): T { + return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.has(key))) as T; +} + +/** The parsed permission document, or null when the text is not a JSON object. */ +export function parseRolePermissionText(text: string | undefined): LocalRolePermission | null { + if (!text) { + return null; + } + try { + // Not safeParse: it console.errors, and mid-edit text is invalid on most keystrokes. + const parsed: unknown = JSON.parse(text); + return isPlainRecord(parsed) ? parsed as LocalRolePermission : null; + } catch { + return null; + } +} + +export function serializeRolePermission(permission: LocalRolePermission): string { + return JSON.stringify(orderPermissionKeys(permission), null, 2); +} + +/** + * Databases the editor offers, in describe_all order. A database named like a reserved permission + * key cannot be granted in role JSON, except `operations` below the allowlist floor, where Harper + * reserves nothing by that name. + */ +export function getEditableDatabaseNames(instanceDatabaseMap: InstanceDatabaseMap, version: string): string[] { + const allowlistSupported = supportsOperationsAllowlist(version); + return Object.keys(instanceDatabaseMap).filter((name) => + !RESERVED_PERMISSION_KEYS.has(name) || (name === 'operations' && !allowlistSupported) + ); +} + +export function getTablePrimaryKey(table: InstanceTable): string | undefined { + return table.primary_key ?? table.hash_attribute + ?? table.attributes.find((attribute) => attribute.is_primary_key)?.attribute; +} + +/** Keys of the document that name databases rather than role flags or the allowlist. */ +function getDatabaseKeys(permission: LocalRolePermission, allowlistSupported: boolean): string[] { + return Object.keys(permission).filter((key) => + !RESERVED_PERMISSION_KEYS.has(key) + || (key === 'operations' && getDatabasePermissionRecord(permission, key, allowlistSupported) !== undefined) + ); +} + +/** + * Why the visual editor cannot show this document, or an empty list when it can. Anything listed + * here is a shape the GUI could only render by guessing or would rewrite on its first edit, so the + * JSON view stays authoritative until it is fixed. Most are shapes Harper's role validation + * rejects anyway (`role_validation.ts` — flag types, table CRUD booleans, attribute_permissions + * array-or-null, attribute entry shape); the operations kinds are the ones the existing notice + * explains. + */ +export function getVisualEditorBlockers( + permission: LocalRolePermission, + instanceDatabaseMap: InstanceDatabaseMap, + version: string, +): string[] { + if (isLegacyRoleFormat(version)) { + return ['This Harper version uses the role format from before 2.2, which only the JSON editor supports.']; + } + const blockers: string[] = []; + const allowlistSupported = supportsOperationsAllowlist(version); + for (const flag of ['super_user', 'cluster_user'] as const) { + if (Object.hasOwn(permission, flag) && typeof permission[flag] !== 'boolean') { + blockers.push(`"${flag}" must be true or false.`); + } + } + if (permission.super_user === true && permission.cluster_user === true) { + blockers.push('"super_user" and "cluster_user" cannot both be true.'); + } + const structureUser = permission.structure_user; + if ( + structureUser !== undefined && typeof structureUser !== 'boolean' + && !(Array.isArray(structureUser) && structureUser.every((name) => typeof name === 'string')) + ) { + blockers.push('"structure_user" must be true, false, or a list of database names.'); + } + const operationsKind = classifyOperationsValue(permission, allowlistSupported); + if (operationsKind === 'breaks-auth' || operationsKind === 'malformed') { + blockers.push('"operations" is not a list of operation names.'); + } + + for (const databaseName of getEditableDatabaseNames(instanceDatabaseMap, version)) { + if (!Object.hasOwn(permission, databaseName)) { + continue; + } + const record = permission[databaseName]; + if (!isPlainRecord(record)) { + blockers.push(`The entry for database "${databaseName}" is not an object.`); + continue; + } + const tables = ownValue(record, 'tables'); + if (tables === undefined) { + continue; + } + if (!isPlainRecord(tables)) { + blockers.push(`"${databaseName}.tables" is not an object.`); + continue; + } + for (const tableName of Object.keys(instanceDatabaseMap[databaseName])) { + const entry = ownValue(tables, tableName); + if (entry !== undefined) { + blockers.push(...getTableEntryBlockers(entry, `${databaseName}.${tableName}`)); + } + } + } + return [...new Set(blockers)]; +} + +function getTableEntryBlockers(entry: unknown, label: string): string[] { + if (!isPlainRecord(entry)) { + return [`The entry for table "${label}" is not an object.`]; + } + if (Object.hasOwn(entry, 'attribute_restrictions')) { + return [`"${label}" uses "attribute_restrictions", the role format from before Harper 2.2.`]; + } + const blockers: string[] = []; + const nonBoolean = TABLE_ACTIONS.filter((action) => typeof entry[action] !== 'boolean'); + if (nonBoolean.length) { + blockers.push(`"${label}": ${nonBoolean.join(', ')} must each be true or false.`); + } + const attributePermissions = ownValue(entry, 'attribute_permissions'); + if (attributePermissions === null) { + return blockers; + } + if (!Array.isArray(attributePermissions)) { + blockers.push(`"${label}.attribute_permissions" must be a list.`); + return blockers; + } + const seen = new Set(); + for (const attributeEntry of attributePermissions) { + if (!isPlainRecord(attributeEntry) || typeof attributeEntry.attribute_name !== 'string') { + blockers.push(`"${label}.attribute_permissions" has an entry without an attribute_name.`); + continue; + } + const name = attributeEntry.attribute_name; + if (seen.has(name)) { + blockers.push(`"${label}.attribute_permissions" lists "${name}" more than once.`); + } + seen.add(name); + if (ATTRIBUTE_ACTIONS.some((action) => typeof attributeEntry[action] !== 'boolean')) { + blockers.push(`"${label}.attribute_permissions": "${name}" needs read, insert and update set to true or false.`); + } + } + return blockers; +} + +function isLegacyRoleFormat(version: string): boolean { + // compareVersions orders an unparseable version below everything; only a real pre-2.2 is legacy. + return compareVersions(version, '0.0.0') >= 0 && compareVersions(version, '2.2.0') < 0; +} + +export interface PreservedEntries { + /** Databases and tables the role names that this instance's describe_all does not list. */ + notInSchema: string[]; + /** Other content the GUI does not render; kept exactly as it is. */ + notShown: string[]; +} + +/** + * What the document holds that the visual editor does not render. `notInSchema` is separated + * because Harper's role validation rejects a role naming a database or table that does not exist + * (`role_validation.ts` SCHEMA_NOT_FOUND / TABLE_NOT_FOUND), so those block a save until removed. + * Attribute entries the schema does not list are only "not shown": describe_all omits `@computed` + * attributes unless asked for them, so a missing name may well be a real attribute. + */ +export function getPreservedEntries( + permission: LocalRolePermission, + instanceDatabaseMap: InstanceDatabaseMap, + version: string, +): PreservedEntries { + const allowlistSupported = supportsOperationsAllowlist(version); + const editable = new Set(getEditableDatabaseNames(instanceDatabaseMap, version)); + const notInSchema: string[] = []; + const notShown: string[] = []; + + if (Array.isArray(permission.structure_user)) { + for (const name of permission.structure_user) { + if (!editable.has(name)) { + notInSchema.push(`structure_user database "${name}"`); + } + } + } + if ( + !allowlistSupported && permission.operations !== undefined + && !getDatabaseKeys(permission, false).includes('operations') + ) { + notShown.push('"operations" (has no effect before Harper 5.0)'); + } + + for (const databaseName of getDatabaseKeys(permission, allowlistSupported)) { + if (!editable.has(databaseName)) { + notInSchema.push(`database "${databaseName}"`); + continue; + } + const record = permission[databaseName]; + if (!isPlainRecord(record)) { + continue; + } + for (const key of Object.keys(record)) { + if (key !== 'tables') { + notShown.push(`"${databaseName}.${key}"`); + } + } + const tables = ownValue(record, 'tables'); + if (!isPlainRecord(tables)) { + continue; + } + const schemaTables = instanceDatabaseMap[databaseName]; + for (const tableName of Object.keys(tables)) { + const schemaTable = Object.hasOwn(schemaTables, tableName) ? schemaTables[tableName] : undefined; + if (!schemaTable) { + notInSchema.push(`table "${databaseName}.${tableName}"`); + continue; + } + const entry = tables[tableName]; + if (!isPlainRecord(entry)) { + continue; + } + const label = `${databaseName}.${tableName}`; + for (const key of Object.keys(entry)) { + if (!TABLE_ENTRY_KEYS.has(key)) { + notShown.push(`"${label}.${key}"`); + } + } + const attributePermissions = ownValue(entry, 'attribute_permissions'); + if (!Array.isArray(attributePermissions)) { + continue; + } + const schemaAttributes = new Set(schemaTable.attributes.map((attribute) => attribute.attribute)); + for (const attributeEntry of attributePermissions) { + if (!isPlainRecord(attributeEntry)) { + continue; + } + const name = String(attributeEntry.attribute_name); + if (!schemaAttributes.has(name)) { + notShown.push(`attribute "${label}.${name}"`); + } + for (const key of Object.keys(attributeEntry)) { + if (!ATTRIBUTE_ENTRY_KEYS.has(key)) { + notShown.push(`"${key}" on attribute "${label}.${name}"`); + } + } + } + } + } + return { notInSchema, notShown }; +} + +/** + * Removes exactly the `notInSchema` entries from getPreservedEntries: database keys and table keys + * the schema does not list, and structure_user names that are not databases here. + */ +export function removeEntriesNotInSchema( + permission: LocalRolePermission, + instanceDatabaseMap: InstanceDatabaseMap, + version: string, +): LocalRolePermission { + const allowlistSupported = supportsOperationsAllowlist(version); + const editable = new Set(getEditableDatabaseNames(instanceDatabaseMap, version)); + const missingDatabases = new Set( + getDatabaseKeys(permission, allowlistSupported).filter((name) => !editable.has(name)), + ); + let next = withoutKeys(permission, missingDatabases); + if (Array.isArray(next.structure_user)) { + next = withKey(next, 'structure_user', next.structure_user.filter((name) => editable.has(name))); + } + for (const databaseName of editable) { + const record = ownValue(next, databaseName); + const tables = isPlainRecord(record) ? ownValue(record, 'tables') : undefined; + if (!isPlainRecord(record) || !isPlainRecord(tables)) { + continue; + } + const schemaTables = instanceDatabaseMap[databaseName]; + const missingTables = new Set(Object.keys(tables).filter((name) => !Object.hasOwn(schemaTables, name))); + if (missingTables.size) { + next = withKey(next, databaseName, withKey(record, 'tables', withoutKeys(tables, missingTables))); + } + } + return next; +} + +export function getRoleType(permission: LocalRolePermission): RoleType { + if (permission.super_user === true) { + return 'super_user'; + } + return permission.cluster_user === true ? 'cluster_user' : 'standard'; +} + +/** + * Sets the exclusive role flag. A flag being turned off is written as `false` only where the key + * already exists, so a document that never carried it does not grow one. Everything else stays in + * the document until save, where preparePermissionForSave drops what Harper refuses alongside + * super_user/cluster_user — so flipping the type back within a session loses nothing. + */ +export function setRoleType(permission: LocalRolePermission, roleType: RoleType): LocalRolePermission { + let next = permission; + for (const flag of ['super_user', 'cluster_user'] as const) { + if (roleType === flag) { + next = withKey(next, flag, true); + } else if (Object.hasOwn(next, flag)) { + next = withKey(next, flag, false); + } + } + return orderPermissionKeys(next); +} + +export function setStructureUser(permission: LocalRolePermission, value: boolean | string[]): LocalRolePermission { + if (value === false && !Object.hasOwn(permission, 'structure_user')) { + return permission; + } + return orderPermissionKeys(withKey(permission, 'structure_user', value)); +} + +/** + * The document's record for a database, typed loosely on purpose: it may carry keys beside + * `tables` (Harper's translator honours `describe`), and every one of them is carried over. + */ +export function getDatabaseRecord(permission: LocalRolePermission, databaseName: string): PlainRecord | undefined { + const record = ownValue(permission, databaseName); + return isPlainRecord(record) ? record : undefined; +} + +/** A table entry within a database record, when it is in a shape the editor accepts. */ +export function getTableEntryInRecord( + record: PlainRecord | undefined, + tableName: string, +): LocalRolePermissionTable | undefined { + const tables = record && ownValue(record, 'tables'); + const entry = isPlainRecord(tables) ? ownValue(tables, tableName) : undefined; + return isPlainRecord(entry) ? entry as unknown as LocalRolePermissionTable : undefined; +} + +export function getTableEntry( + permission: LocalRolePermission, + databaseName: string, + tableName: string, +): LocalRolePermissionTable | undefined { + return getTableEntryInRecord(getDatabaseRecord(permission, databaseName), tableName); +} + +export function isTableScopedToAttributes(entry: LocalRolePermissionTable | undefined): boolean { + // permissionsTranslator scopes only on a non-empty array; `[]` and `null` both mean "every attribute". + const attributePermissions = entry?.attribute_permissions; + return Array.isArray(attributePermissions) && attributePermissions.length > 0; +} + +function newTableEntry(): LocalRolePermissionTable { + return { read: false, insert: false, update: false, delete: false, attribute_permissions: [] }; +} + +function withTableEntry( + permission: LocalRolePermission, + databaseName: string, + tableName: string, + entry: LocalRolePermissionTable, +): LocalRolePermission { + const record = ownValue(permission, databaseName); + const base = isPlainRecord(record) ? record : {}; + const tables = ownValue(base, 'tables'); + const nextTables = withKey(isPlainRecord(tables) ? tables : {}, tableName, entry); + return withKey(permission, databaseName, withKey(base, 'tables', nextTables)); +} + +/** + * The entry with one table action set. Turning a read/insert/update off also turns it off on every + * attribute entry, because role validation rejects an attribute granting what its table does not + * (MISMATCHED_TABLE_ATTR_PERMS). + */ +function withTableAction( + entry: LocalRolePermissionTable, + action: LocalRolePermissionAction, + value: boolean, +): LocalRolePermissionTable { + const next = { ...entry, [action]: value }; + if (!value && action !== 'delete' && Array.isArray(entry.attribute_permissions)) { + next.attribute_permissions = entry.attribute_permissions.map((attribute) => + attribute[action] ? { ...attribute, [action]: false } : attribute + ); + } + return next; +} + +export function setTableAction( + permission: LocalRolePermission, + databaseName: string, + tableName: string, + action: LocalRolePermissionAction, + value: boolean, +): LocalRolePermission { + const entry = getTableEntry(permission, databaseName, tableName); + if (!entry && !value) { + // A table the role does not mention already has no access (permissionsTranslator). + return permission; + } + return withTableEntry(permission, databaseName, tableName, withTableAction(entry ?? newTableEntry(), action, value)); +} + +/** Sets one action on every listed table of a database in a single rebuild. */ +export function setDatabaseAction( + permission: LocalRolePermission, + databaseName: string, + tableNames: string[], + action: LocalRolePermissionAction, + value: boolean, +): LocalRolePermission { + const record = ownValue(permission, databaseName); + const base = isPlainRecord(record) ? record : {}; + const tables = ownValue(base, 'tables'); + const existing = isPlainRecord(tables) ? tables : {}; + const pending = new Map(); + for (const tableName of tableNames) { + const entry = getTableEntry(permission, databaseName, tableName); + if (entry || value) { + pending.set(tableName, withTableAction(entry ?? newTableEntry(), action, value)); + } + } + if (!pending.size) { + return permission; + } + const entries = Object.entries(existing).map(([name, entry]): [string, unknown] => { + const replacement = pending.get(name); + pending.delete(name); + return [name, replacement ?? entry]; + }); + entries.push(...pending.entries()); + return withKey(permission, databaseName, withKey(base, 'tables', Object.fromEntries(entries))); +} + +export interface AttributeView { + name: string; + read: boolean; + insert: boolean; + update: boolean; + isPrimaryKey: boolean; + /** Only read applies (permissionsTranslator replaces the entry with a read-only one). */ + isTimestamp: boolean; + /** An unlisted primary key, whose access Harper derives from the listed attributes. */ + derived: boolean; +} + +/** + * Effective attribute access for a scoped table, as permissionsTranslator's getTableAttrPerms + * computes it: listed attributes as listed, unlisted ones get nothing, except an unlisted primary + * key, which gets each action any listed attribute has. + */ +export function getAttributeViews( + entry: LocalRolePermissionTable, + attributeNames: string[], + primaryKey: string | undefined, +): AttributeView[] { + const listed = new Map( + (entry.attribute_permissions ?? []).map((attribute) => [attribute.attribute_name, attribute]), + ); + const effective = (name: string, attribute: LocalRoleAttributePermissionTable | undefined) => { + const timestamp = TIMESTAMP_ATTRIBUTES.has(name); + return { + read: attribute?.read === true, + insert: !timestamp && attribute?.insert === true, + update: !timestamp && attribute?.update === true, + }; + }; + const derivedPrimaryKey = { read: false, insert: false, update: false }; + for (const name of attributeNames) { + const attribute = listed.get(name); + if (name !== primaryKey && attribute) { + const access = effective(name, attribute); + for (const action of ATTRIBUTE_ACTIONS) { + derivedPrimaryKey[action] ||= access[action]; + } + } + } + return attributeNames.map((name) => { + const attribute = listed.get(name); + const isPrimaryKey = name === primaryKey; + const derived = isPrimaryKey && !attribute; + return { + name, + ...(derived ? derivedPrimaryKey : effective(name, attribute)), + isPrimaryKey, + isTimestamp: TIMESTAMP_ATTRIBUTES.has(name), + derived, + }; + }); +} + +/** + * Turns attribute scoping on or off for one table. Turning it on lists every attribute with the + * table's own read/insert/update, so the role's current access does not change at the moment of + * switching — only attributes added to the table later stay unreadable until granted. Turning it + * off writes `[]`; `restore` brings back a list the author turned off this session, less any action + * the table has lost since, which role validation would reject on the attribute. + */ +export function setAttributeScoping( + permission: LocalRolePermission, + databaseName: string, + tableName: string, + scoped: boolean, + attributeNames: string[], + restore?: LocalRoleAttributePermissionTable[], +): LocalRolePermission { + const entry = getTableEntry(permission, databaseName, tableName) ?? newTableEntry(); + const attributePermissions = scoped + ? restore?.length + ? restore.map((attribute) => ({ + ...attribute, + read: attribute.read && entry.read, + insert: attribute.insert && entry.insert, + update: attribute.update && entry.update, + })) + : attributeNames.map((name) => ({ + attribute_name: name, + read: entry.read, + insert: TIMESTAMP_ATTRIBUTES.has(name) ? false : entry.insert, + update: TIMESTAMP_ATTRIBUTES.has(name) ? false : entry.update, + })) + : []; + return withTableEntry(permission, databaseName, tableName, { ...entry, attribute_permissions: attributePermissions }); +} + +/** + * Sets one action on one attribute of a scoped table. An unlisted attribute is added with its + * effective access first (so a derived primary key keeps what it had), and granting an action the + * table lacks also grants it on the table, which role validation requires. + */ +export function setAttributeAction( + permission: LocalRolePermission, + databaseName: string, + tableName: string, + attributeNames: string[], + primaryKey: string | undefined, + attributeName: string, + action: LocalRoleAttributePermissionAction, + value: boolean, +): LocalRolePermission { + const entry = getTableEntry(permission, databaseName, tableName); + if (!entry || !Array.isArray(entry.attribute_permissions)) { + return permission; + } + const list = entry.attribute_permissions; + const index = list.findIndex((attribute) => attribute.attribute_name === attributeName); + let nextList: LocalRoleAttributePermissionTable[]; + if (index >= 0) { + nextList = list.map((attribute, position) => position === index ? { ...attribute, [action]: value } : attribute); + } else { + const view = getAttributeViews(entry, attributeNames, primaryKey).find((item) => item.name === attributeName); + nextList = [...list, { + attribute_name: attributeName, + read: view?.read ?? false, + insert: view?.insert ?? false, + update: view?.update ?? false, + [action]: value, + }]; + } + const nextEntry = { + ...entry, + attribute_permissions: nextList, + ...(value && !entry[action] ? { [action]: true } : {}), + }; + return withTableEntry(permission, databaseName, tableName, nextEntry); +} diff --git a/src/features/instance/config/roles/editor/useRolePermissionDraft.ts b/src/features/instance/config/roles/editor/useRolePermissionDraft.ts new file mode 100644 index 000000000..4faad7eb2 --- /dev/null +++ b/src/features/instance/config/roles/editor/useRolePermissionDraft.ts @@ -0,0 +1,32 @@ +import { + parseRolePermissionText, + serializeRolePermission, +} from '@/features/instance/config/roles/editor/rolePermissionModel'; +import { LocalRolePermission } from '@/integrations/api/api.patch'; +import { orderPermissionKeys } from '@/integrations/api/localRolePermission'; +import { useCallback, useState } from 'react'; + +export interface RolePermissionDraft { + /** The source of truth, as the JSON view shows it. */ + text: string; + /** `text` parsed, or null while it isn't a JSON object. */ + permission: LocalRolePermission | null; + setText: (text: string) => void; + setPermission: (permission: LocalRolePermission) => void; +} + +/** + * The role editor's draft. A visual edit keeps the document it produced instead of re-parsing the + * text it serializes to, so every database and table the edit did not touch keeps its object + * identity — which is what lets the editor skip re-rendering them on a large schema. + */ +export function useRolePermissionDraft(initial: LocalRolePermission): RolePermissionDraft { + const [draft, setDraft] = useState>(() => fromPermission(initial)); + const setText = useCallback((text: string) => setDraft({ text, permission: parseRolePermissionText(text) }), []); + const setPermission = useCallback((permission: LocalRolePermission) => setDraft(fromPermission(permission)), []); + return { ...draft, setText, setPermission }; +} + +function fromPermission(permission: LocalRolePermission) { + return { text: serializeRolePermission(permission), permission: orderPermissionKeys(permission) }; +} diff --git a/src/features/instance/config/roles/index.tsx b/src/features/instance/config/roles/index.tsx index 0272a61f5..f45eedf2d 100644 --- a/src/features/instance/config/roles/index.tsx +++ b/src/features/instance/config/roles/index.tsx @@ -47,11 +47,13 @@ export function ConfigRolesIndex() { const onAddClicked = useCallback(() => { setIsAddModalOpen(true); }, [setIsAddModalOpen]); - const onRoleAdded = useCallback((newRoleId: string) => { + const onRoleAdded = useCallback(() => { void refetch(); setIsAddModalOpen(false); - onSelectRole(newRoleId); - }, [onSelectRole, refetch]); + }, [refetch]); + const closeAddModal = useCallback(() => { + setIsAddModalOpen(false); + }, []); const onRowClick = useCallback( (rowData: Row) => { @@ -88,7 +90,7 @@ export function ConfigRolesIndex() { - + {isAddModalOpen && } {isEditModalOpen && ( ({ addRoleMutate: vi.fn() })); + +vi.mock('@/lib/monaco/MonacoEditor', () => ({ + Editor: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => ( +