From 82267bbc5dd58244d22dfb2c993a79a48c6b9492 Mon Sep 17 00:00:00 2001 From: huyplb Date: Fri, 25 Sep 2026 23:14:11 -0600 Subject: [PATCH] refactor(access): simplify the roles and allow-all code from #435 No behaviour change; a /simplify pass over #435 for readability. - Allow-all is worked out once per catalog read: findAllowAllByName indexes privileges by grantee and answers each role once, instead of re-reading the whole privilege list per account (70M comparisons for 1,000 MySQL accounts with *.* grants). Both lists use it. - One name key, principalKey, shared by privilegesForPrincipal, reconcileDbAccess and the allow-all index; reconcile uses Sets, not list scans, for widely held roles. - SQL Server's implied fixed-role rows are their own function, called beside reconcile, instead of hiding inside "make the catalogs agree". - MySQL account quoting has one home: formatDbGrantee and the new SET DEFAULT ROLE statements use mysqlQuote / mysqlAccount / mysqlRoleRef from user-sql-helpers, the same helpers CREATE USER uses. - The MySQL and ClickHouse GRANT branches shared a copied target rule; now one branch. allPrivilegeTargets returns its lists directly and drops the always-null objectSchema. - DatabaseAccessModal: the privilege table is a file-local PrivilegeGroupRows; one confirmRevoke replaces three copies of the revoke flow; the membership role is derived instead of synced by an effect; the confirm field resets where the confirmation opens; granteeKindOf, which returned its input, is gone. - UserManagement: any MySQL-family name parses as user@host (a MariaDB role has no @ and parses to itself), so the role special case goes. Co-Authored-By: Claude Opus 5.5 --- .../access/components/UserManagement.tsx | 35 +- .../components/DatabaseAccessModal.tsx | 411 +++++++++--------- .../src/features/access/db-access.service.ts | 9 +- packages/sql/src/index.ts | 3 + .../sql/src/modules/access/db-access.test.ts | 20 +- packages/sql/src/modules/access/db-access.ts | 193 ++++---- .../modules/access/privilege-groups.test.ts | 23 +- .../src/modules/access/privilege-groups.ts | 194 ++++++--- .../src/modules/access/user-sql-helpers.ts | 2 +- packages/sql/src/modules/access/user-sql.ts | 36 +- 10 files changed, 490 insertions(+), 436 deletions(-) diff --git a/apps/web/src/frontend/features/access/components/UserManagement.tsx b/apps/web/src/frontend/features/access/components/UserManagement.tsx index 8c8848db..1283a7e5 100644 --- a/apps/web/src/frontend/features/access/components/UserManagement.tsx +++ b/apps/web/src/frontend/features/access/components/UserManagement.tsx @@ -75,7 +75,7 @@ import { } from '../lib/accountAlterations'; import type { AccessPrincipalDraft } from '../lib/access'; import { writeClipboard } from '@/shared/utils/clipboard'; -import { describeAllowAll, dialectFamily, findAllowAll, type AllowAll } from '@foxschema/sql'; +import { describeAllowAll, dialectFamily, findAllowAllByName, type AllowAll } from '@foxschema/sql'; type Mode = 'idle' | 'add' | 'edit' | 'drop' | 'list'; @@ -299,15 +299,11 @@ export const UserManagement: React.FC<{ }, [principals, filter, kindFilter]); /** Accounts allowed everything — superuser, all of `*.*`, or the whole database. */ - const allowAllByName = useMemo(() => { - const map = new Map(); - if (!dialect) return map; - for (const p of principals) { - const allow = findAllowAll({ principal: p.name, principals, privileges, dialect }); - if (allow) map.set(p.name, allow); - } - return map; - }, [principals, privileges, dialect]); + const allowAllByName = useMemo( + () => + dialect ? findAllowAllByName({ principals, privileges, dialect }) : new Map(), + [principals, privileges, dialect] + ); const nameOptions = useMemo( () => @@ -595,9 +591,9 @@ export const UserManagement: React.FC<{ setMode('drop'); setSelectedName(p.name); setPrincipalType(principalTypeOf(p)); - // A MySQL or TiDB role is an account too, listed as name@host; a MariaDB - // role has no host and no @ in its name. - if (isMysqlFamily && (p.kind === 'user' || p.name.includes('@'))) { + // Users and MySQL/TiDB roles are listed as name@host; a MariaDB role has + // no @ and parses to its bare name. + if (isMysqlFamily) { const parsed = parseMysqlAccount(p.name); setName(parsed.name); setHost(parsed.host || '%'); @@ -611,9 +607,9 @@ export const UserManagement: React.FC<{ setMode('edit'); setSelectedName(p.name); setPrincipalType(principalTypeOf(p)); - // A MySQL or TiDB role is an account too, listed as name@host; a MariaDB - // role has no host and no @ in its name. - if (isMysqlFamily && (p.kind === 'user' || p.name.includes('@'))) { + // Users and MySQL/TiDB roles are listed as name@host; a MariaDB role has + // no @ and parses to its bare name. + if (isMysqlFamily) { const parsed = parseMysqlAccount(p.name); setName(parsed.name); setHost(parsed.host || '%'); @@ -991,6 +987,7 @@ export const UserManagement: React.FC<{ )} {filtered.map((p) => { const active = selectedName === p.name; + const allow = allowAllByName.get(p.name); return ( {p.name} - {allowAllByName.get(p.name) && ( + {allow && ( - {allowAllByName.get(p.name)!.kind === 'superuser' ? 'superuser' : 'allow-all'} + {allow.kind === 'superuser' ? 'superuser' : 'allow-all'} )} diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx index 3839b822..81898218 100644 --- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx @@ -22,7 +22,7 @@ import { buildGrantRevokeSql, describeAllowAll, dialectSupportsDbAccess, - findAllowAll, + findAllowAllByName, groupDbPrincipals, groupPrivileges, privilegeTargetLabel, @@ -31,6 +31,7 @@ import { type DbPrincipal, type DbPrivilege, type DbPrivilegeObjectType, + type PrivilegeGroup, } from '@foxschema/sql'; import { PERMISSION_META } from '@foxschema/shared'; import { PasswordInput } from '@/shared/components/PasswordInput'; @@ -78,20 +79,141 @@ function privilegeSummary(names: readonly string[]): string { return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } -function granteeKindOf(p: DbPrincipal): 'user' | 'role' | 'group' { - return p.kind === 'group' ? 'group' : p.kind === 'role' ? 'role' : 'user'; -} - /** Marks an account allowed everything. The word, not a colour, carries it. */ -const AllowAllTag: React.FC<{ allow: AllowAll; testId: string }> = ({ allow, testId }) => ( - - {allow.kind === 'superuser' ? 'superuser' : 'allow-all'} - -); +const PrincipalAllowAllTag: React.FC<{ allow: AllowAll | undefined; name: string }> = ({ allow, name }) => + allow ? ( + + {allow.kind === 'superuser' ? 'superuser' : 'allow-all'} + + ) : null; + +/** What one Revoke button takes away. */ +type RevokeTarget = Pick; + +const revokeButtonCls = + 'text-[10px] font-bold uppercase tracking-wide text-rose-300 hover:text-rose-100 disabled:opacity-40'; + +/** + * One object's privileges in the table. + * + * One privilege is one row. More than one collapses into a header that + * expands, and a complete set says ALL PRIVILEGES — seventy rows for `ON *.*` + * hid the one fact that mattered. + */ +const PrivilegeGroupRows: React.FC<{ + group: PrivilegeGroup; + /** Position among the groups, for test ids. */ + index: number; + open: boolean; + onToggle: () => void; + /** Where a privilege sits in the principal's list, for test ids. */ + rowIndex: (priv: DbPrivilege) => number; + revokeDisabled: boolean; + onRevoke: (target: RevokeTarget, title: string) => void; +}> = ({ group, index, open, onToggle, rowIndex, revokeDisabled, onRevoke }) => { + const on = privilegeTargetLabel(group); + const single = group.privileges.length === 1 && !group.all; + const expanded = single || open; + const implied = group.privileges.some((p) => p.source === 'implied'); + const names = group.privileges.map((p) => p.privilege); + const Chevron = expanded ? ChevronDown : ChevronRight; + return ( + <> + {!single && ( + + + + ({group.privileges.length}) + + {on} + + {group.all && !implied && group.state !== 'deny' && ( + + )} + + + )} + {expanded && + group.privileges.map((priv) => { + const i = rowIndex(priv); + return ( + + + {priv.state === 'deny' ? 'DENY ' : ''} + {priv.privilege} + {priv.grantable && ( + + grantable + + )} + {priv.source === 'implied' && ( + implied by this fixed role + )} + + {single ? on : ''} + + {priv.source !== 'implied' && ( + + )} + + + ); + })} + + ); +}; + const GRANT_PRIV_META = PERMISSION_META.find((m) => m.id === 'editor.grant'); export const DatabaseAccessModal: React.FC = ({ @@ -198,15 +320,11 @@ export const DatabaseAccessModal: React.FC = ({ * Who may do anything, and through whom. Worked out once per catalog read: * the tag in the list, the filter and the banner all read this. */ - const allowAllByName = useMemo(() => { - const map = new Map(); - if (!dialect) return map; - for (const p of principals) { - const allow = findAllowAll({ principal: p.name, principals, privileges, dialect }); - if (allow) map.set(p.name, allow); - } - return map; - }, [principals, privileges, dialect]); + const allowAllByName = useMemo( + () => + dialect ? findAllowAllByName({ principals, privileges, dialect }) : new Map(), + [principals, privileges, dialect] + ); const groups = useMemo(() => { const q = filter.trim().toLowerCase(); @@ -253,17 +371,18 @@ export const DatabaseAccessModal: React.FC = ({ ); const [roleChoice, setRoleChoice] = useState(''); const typingRole = roleChoices.length === 0 || roleChoice === OTHER_ROLE; + /** The role the membership form grants: typed under "Other…", else picked (the first by default). */ + const membershipRole = typingRole + ? grantName + : roleChoices.includes(roleChoice) + ? roleChoice + : roleChoices[0] ?? ''; useEffect(() => { - // A new principal means a new list; start on its first role, not a stale one. + // A new principal means a new list, and a name typed for the last one + // must not carry over. setRoleChoice(''); setGrantName(''); }, [selectedName]); - useEffect(() => { - if (grantKind !== 'membership' || typingRole) return; - const pick = roleChoices.includes(roleChoice) ? roleChoice : roleChoices[0] ?? ''; - if (pick !== roleChoice) setRoleChoice(pick); - if (pick !== grantName) setGrantName(pick); - }, [grantKind, typingRole, roleChoices, roleChoice, grantName]); const togglePrivGroup = (key: string) => { setOpenPrivGroups((prev) => { @@ -279,12 +398,12 @@ export const DatabaseAccessModal: React.FC = ({ const built = buildGrantRevokeSql({ dialect, action: 'grant', - privilege: grantName || grantPrivilege, + privilege: membershipRole || grantPrivilege, objectType: 'ROLE', objectSchema: grantSchema || conn?.schema || null, - objectName: grantName || null, + objectName: membershipRole || null, grantee: selected.name, - granteeKind: selected.kind === 'group' ? 'group' : selected.kind === 'role' ? 'role' : 'user', + granteeKind: selected.kind, withGrantOption: grantWithOption, }); return built; @@ -294,7 +413,7 @@ export const DatabaseAccessModal: React.FC = ({ grantKind, grantPrivilege, grantSchema, - grantName, + membershipRole, grantWithOption, conn?.schema, ]); @@ -312,17 +431,29 @@ export const DatabaseAccessModal: React.FC = ({ action: 'grant', privilege: allTarget.privilege, objectType: allTarget.objectType, - objectSchema: allTarget.objectSchema, + objectSchema: null, objectName: allTarget.objectName, grantee: selected.name, - granteeKind: granteeKindOf(selected), + granteeKind: selected.kind, }); }, [dialect, selected, grantKind, allTarget]); - useEffect(() => { - // A new confirmation starts empty: a name typed for the last one must not - // arm this one. - setConfirmTyped(''); - }, [confirm]); + + /** Build a REVOKE for the selected principal and ask before running it. */ + const confirmRevoke = (target: RevokeTarget, title: string) => { + if (!selected) return; + const built = buildGrantRevokeSql({ + dialect, + action: 'revoke', + ...target, + grantee: selected.name, + granteeKind: selected.kind, + }); + if ('error' in built) { + setError(built.error); + return; + } + setConfirm({ title, sql: built.sql, kind: 'revoke' }); + }; const runSql = async (sql: string, kind: 'grant' | 'revoke') => { if (!connectionId || !canGrant) return; @@ -530,12 +661,7 @@ export const DatabaseAccessModal: React.FC = ({ }`} > {p.name} - {allowAllByName.get(p.name) && ( - - )} + {p.kind !== 'user' && p.members.length > 0 && ( {p.members.length} member{p.members.length === 1 ? '' : 's'} @@ -607,140 +733,18 @@ export const DatabaseAccessModal: React.FC = ({ - {selectedPrivGroups.map((group, gi) => { - const on = privilegeTargetLabel(group); - const deny = group.state === 'deny' ? 'DENY ' : ''; - // One privilege on an object needs no group row. More - // than one collapses, and a complete set says ALL — - // thirty rows for `ON *.*` hid the one fact that mattered. - const single = group.privileges.length === 1 && !group.all; - const expanded = single || openPrivGroups.has(group.key); - const implied = group.privileges.some((p) => p.source === 'implied'); - const Chevron = expanded ? ChevronDown : ChevronRight; - const names = group.privileges.map((p) => p.privilege); - const revokeAll = () => { - // A lone ALL / CONTROL row is revoked by its own name; - // an expanded set by the engine's ALL. - const lone = group.privileges.length === 1 ? group.privileges[0]!.privilege : 'ALL'; - const built = buildGrantRevokeSql({ - dialect, - action: 'revoke', - privilege: lone, - objectType: group.objectType, - objectSchema: group.objectSchema, - objectName: group.objectName, - grantee: selected.name, - granteeKind: granteeKindOf(selected), - }); - if ('error' in built) { - setError(built.error); - return; - } - setConfirm({ title: 'Revoke all privileges', sql: built.sql, kind: 'revoke' }); - }; - return ( - - {!single && ( - - - - - ({group.privileges.length}) - - - {on} - - {group.all && !implied && group.state !== 'deny' && ( - - )} - - - )} - {expanded && - group.privileges.map((priv) => { - const i = selectedPrivs.indexOf(priv); - return ( - - - {priv.state === 'deny' ? 'DENY ' : ''} - {priv.privilege} - {priv.grantable && ( - - grantable - - )} - {priv.source === 'implied' && ( - - implied by this fixed role - - )} - - {single ? on : ''} - - {priv.source !== 'implied' && ( - - )} - - - ); - })} - - ); - })} + {selectedPrivGroups.map((group, gi) => ( + togglePrivGroup(group.key)} + rowIndex={(priv) => selectedPrivs.indexOf(priv)} + revokeDisabled={!canGrant || running || !support?.grant} + onRevoke={confirmRevoke} + /> + ))} )} @@ -784,33 +788,18 @@ export const DatabaseAccessModal: React.FC = ({ type="button" data-testid={`db-access-remove-member-${i}`} disabled={!canGrant || running || !support?.grant} - onClick={() => { - const built = buildGrantRevokeSql({ - dialect, - action: 'revoke', - privilege: priv.privilege, - objectType: 'ROLE', - objectSchema: priv.objectSchema, - objectName: priv.objectName, - grantee: selected.name, - granteeKind: - selected.kind === 'group' - ? 'group' - : selected.kind === 'role' - ? 'role' - : 'user', - }); - if ('error' in built) { - setError(built.error); - return; - } - setConfirm({ - title: 'Remove from role', - sql: built.sql, - kind: 'revoke', - }); - }} - className="text-[10px] font-bold uppercase tracking-wide text-rose-300 hover:text-rose-100 disabled:opacity-40" + onClick={() => + confirmRevoke( + { + privilege: priv.privilege, + objectType: 'ROLE', + objectSchema: priv.objectSchema, + objectName: priv.objectName, + }, + 'Remove from role' + ) + } + className={revokeButtonCls} > Remove @@ -933,6 +922,8 @@ export const DatabaseAccessModal: React.FC = ({ } onClick={() => { if (!allPreview || 'error' in allPreview || !selected) return; + // A name typed for an earlier confirmation must not arm this one. + setConfirmTyped(''); setConfirm({ title: 'Grant all privileges', sql: allPreview.sql, @@ -956,7 +947,7 @@ export const DatabaseAccessModal: React.FC = ({ Role