();
+ 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 nameOptions = useMemo(
() =>
principals
@@ -316,10 +333,13 @@ export const UserManagement: React.FC<{
newName,
alteration,
validUntil: alteration === 'expire' ? validUntil : undefined,
- host: isMysqlFamily && principalType === 'user' ? host : undefined,
+ // Roles carry the host too: a MySQL role is `'r'@'%'`, and a role listed
+ // with another host must be dropped by that one. MariaDB ignores it.
+ host: isMysqlFamily ? host : undefined,
cascade,
+ roles: action === 'create' ? memberRoles : undefined,
}),
- [action, principalType, name, newName, alteration, validUntil, host, isMysqlFamily, cascade]
+ [action, principalType, name, newName, alteration, validUntil, host, isMysqlFamily, cascade, memberRoles]
);
const generated = useMemo(() => {
@@ -549,6 +569,7 @@ export const UserManagement: React.FC<{
setListWarning(null);
setName('');
setFilter('');
+ setMemberRoles([]);
}, [connectionId]);
useEffect(() => {
@@ -574,7 +595,9 @@ export const UserManagement: React.FC<{
setMode('drop');
setSelectedName(p.name);
setPrincipalType(principalTypeOf(p));
- if (isMysqlFamily && p.kind === 'user') {
+ // 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('@'))) {
const parsed = parseMysqlAccount(p.name);
setName(parsed.name);
setHost(parsed.host || '%');
@@ -588,7 +611,9 @@ export const UserManagement: React.FC<{
setMode('edit');
setSelectedName(p.name);
setPrincipalType(principalTypeOf(p));
- if (isMysqlFamily && p.kind === 'user') {
+ // 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('@'))) {
const parsed = parseMysqlAccount(p.name);
setName(parsed.name);
setHost(parsed.host || '%');
@@ -984,7 +1009,18 @@ export const UserManagement: React.FC<{
: 'text-slate-300 hover:bg-slate-900/80'
}`}
>
- {p.name}
+
+ {p.name}
+ {allowAllByName.get(p.name) && (
+
+ {allowAllByName.get(p.name)!.kind === 'superuser' ? 'superuser' : 'allow-all'}
+
+ )}
+
{p.kind}
{p.memberOf.length ? p.memberOf.join(', ') : '—'}
@@ -1280,6 +1316,24 @@ export const UserManagement: React.FC<{
)}
+ {mode === 'add' &&
+ !(isDb2 && principalType === 'user') &&
+ roleOptions.some((o) => o.value !== name.trim()) && (
+
+ o.value).filter((v) => v !== name.trim())}
+ selected={memberRoles}
+ onChange={setMemberRoles}
+ emptyHint="No roles or groups listed for this connection."
+ />
+
+ )}
+
{mode === 'edit' && alteration === 'expire' && support.canExpire && (
o.value)).toEqual(['sections', 'membership']);
+ // Postgres has database-level ALL, so allow-all is offered too.
+ expect([...kind.options].map((o) => o.value)).toEqual(['sections', 'membership', 'all']);
expect(screen.getByTestId('db-access-permission-sections')).toBeTruthy();
fireEvent.change(kind, { target: { value: 'membership' } });
@@ -309,3 +310,174 @@ describe('DatabaseAccessModal — dialect-aware general CREATE', () => {
expect(sql).toMatch(/alice/i);
});
});
+
+describe('DatabaseAccessModal — roles and allow-all', () => {
+ /** What `GRANT ALL PRIVILEGES ON *.*` leaves in MySQL's USER_PRIVILEGES. */
+ const MYSQL_ALL = [
+ 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE USER',
+ 'CREATE VIEW', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'FILE', 'INDEX', 'INSERT', 'LOCK TABLES',
+ 'PROCESS', 'REFERENCES', 'RELOAD', 'REPLICATION CLIENT', 'REPLICATION SLAVE', 'SELECT',
+ 'SHOW DATABASES', 'SHOW VIEW', 'SHUTDOWN', 'SUPER', 'TRIGGER', 'UPDATE',
+ ];
+ const row = (grantee: string, privilege: string, objectType: string, extra: object = {}) => ({
+ grantee,
+ privilege,
+ objectType,
+ objectSchema: null,
+ objectName: null,
+ grantable: false,
+ grantor: null,
+ state: null,
+ ...extra,
+ });
+
+ function catalog(dialect: string, principals: unknown[], privileges: unknown[]) {
+ useSyncStore.setState({
+ connections: [
+ { id: 'c1', name: 'prod', dialect, schema: 'demo_a', database: 'demo_a', hasPassword: true },
+ ],
+ } as never);
+ fetchDbAccess.mockResolvedValue({
+ dialect,
+ schema: 'demo_a',
+ mode: 'native',
+ support: { mode: 'native', query: true, grant: true, hint: 'catalog' },
+ principals,
+ privileges,
+ });
+ }
+
+ async function open(name: string) {
+ render( undefined} />);
+ fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
+ await waitFor(() => expect(fetchDbAccess).toHaveBeenCalled());
+ await waitFor(() => expect(screen.getByTestId(`db-access-principal-${name}`)).toBeTruthy());
+ fireEvent.click(screen.getByTestId(`db-access-principal-${name}`));
+ }
+
+ it('says ALL PRIVILEGES ON *.* once, tags the account, and revokes it whole', async () => {
+ catalog(
+ 'mysql',
+ [{ name: 'app@%', kind: 'user', canLogin: true, memberOf: [], members: [] }],
+ MYSQL_ALL.map((p) => row('app@%', p, 'GLOBAL'))
+ );
+ await open('app@%');
+
+ expect(screen.getByTestId('db-access-allow-all-app@%').textContent).toBe('allow-all');
+ expect(screen.getByTestId('db-access-allow-all-banner').textContent).toMatch(
+ /every privilege on the whole server/
+ );
+ const group = screen.getByTestId('db-access-privgroup-0');
+ expect(group.getAttribute('data-all')).toBe('true');
+ expect(group.textContent).toMatch(/ALL PRIVILEGES/);
+ expect(group.textContent).toMatch(/every database \(\*\.\*\)/);
+ // Collapsed: 27 privileges are not 27 rows until asked for.
+ expect(screen.queryByTestId('db-access-revoke-0')).toBeNull();
+ fireEvent.click(screen.getByTestId('db-access-privgroup-toggle-0'));
+ expect(screen.getByTestId('db-access-revoke-0')).toBeTruthy();
+
+ fireEvent.click(screen.getByTestId('db-access-revoke-all-0'));
+ expect(screen.getByTestId('db-access-confirm').textContent).toMatch(
+ "REVOKE ALL PRIVILEGES ON *.* FROM 'app'@'%';"
+ );
+ });
+
+ it('shows a superuser inherited through a role, and finds it by filter', async () => {
+ catalog(
+ 'postgres',
+ [
+ { name: 'admins', kind: 'role', canLogin: false, memberOf: [], members: ['alice'], superuser: true },
+ { name: 'alice', kind: 'user', canLogin: true, memberOf: ['admins'], members: [], superuser: false },
+ { name: 'bob', kind: 'user', canLogin: true, memberOf: [], members: [], superuser: false },
+ ],
+ []
+ );
+ await open('alice');
+ expect(screen.getByTestId('db-access-allow-all-banner').textContent).toMatch(
+ /Superuser.*Inherited through admins/
+ );
+
+ fireEvent.change(screen.getByTestId('db-access-filter'), { target: { value: 'allow-all' } });
+ expect(screen.queryByTestId('db-access-principal-bob')).toBeNull();
+ expect(screen.getByTestId('db-access-principal-alice')).toBeTruthy();
+ expect(screen.getByTestId('db-access-principal-admins')).toBeTruthy();
+ });
+
+ it('marks WITH GRANT OPTION as grantable, not with an asterisk', async () => {
+ catalog(
+ 'postgres',
+ [{ name: 'alice', kind: 'user', canLogin: true, memberOf: [], members: [] }],
+ [row('alice', 'SELECT', 'TABLE', { objectSchema: 'public', objectName: 'orders', grantable: true })]
+ );
+ await open('alice');
+ const privileges = screen.getByTestId('db-access-privileges').textContent ?? '';
+ expect(screen.getByTestId('db-access-grantable')).toBeTruthy();
+ expect(privileges).not.toMatch(/SELECT \*/);
+ });
+
+ it('offers the roles the principal is not yet in, and still takes a typed name', async () => {
+ catalog(
+ 'postgres',
+ [
+ { name: 'analysts', kind: 'role', canLogin: false, memberOf: [], members: ['alice'] },
+ { name: 'readers', kind: 'role', canLogin: false, memberOf: [], members: [] },
+ { name: 'ops', kind: 'group', canLogin: false, memberOf: [], members: [] },
+ { name: 'alice', kind: 'user', canLogin: true, memberOf: ['analysts'], members: [] },
+ ],
+ [row('alice', 'analysts', 'ROLE', { objectName: 'analysts' })]
+ );
+ await open('alice');
+ fireEvent.change(screen.getByTestId('db-access-grant-kind'), { target: { value: 'membership' } });
+
+ const pick = screen.getByTestId('db-access-grant-role') as HTMLSelectElement;
+ const offered = [...pick.options].map((o) => o.textContent);
+ expect(offered).toEqual(['readers', 'ops', 'Other… (type a name)']);
+ await waitFor(() =>
+ expect(screen.getByTestId('db-access-grant-sql').textContent).toBe('GRANT "readers" TO "alice";')
+ );
+ expect(screen.queryByTestId('db-access-grant-name')).toBeNull();
+
+ fireEvent.change(pick, { target: { value: pick.options[2]!.value } });
+ fireEvent.change(screen.getByTestId('db-access-grant-name'), { target: { value: 'auditors' } });
+ expect(screen.getByTestId('db-access-grant-sql').textContent).toBe('GRANT "auditors" TO "alice";');
+ });
+
+ it('grants everything only after the name is typed, and says what it confers', async () => {
+ catalog(
+ 'mysql',
+ [{ name: 'app@%', kind: 'user', canLogin: true, memberOf: [], members: [] }],
+ []
+ );
+ await open('app@%');
+ fireEvent.change(screen.getByTestId('db-access-grant-kind'), { target: { value: 'all' } });
+
+ const target = screen.getByTestId('db-access-all-target') as HTMLSelectElement;
+ expect([...target.options].map((o) => o.value)).toEqual(['server', 'database']);
+ expect(screen.getByTestId('db-access-all-note').textContent).toMatch(/Critical.*every database/);
+ expect(screen.getByTestId('db-access-grant-sql').textContent).toBe(
+ "GRANT ALL PRIVILEGES ON *.* TO 'app'@'%';"
+ );
+ fireEvent.change(target, { target: { value: 'database' } });
+ expect(screen.getByTestId('db-access-grant-sql').textContent).toBe(
+ "GRANT ALL PRIVILEGES ON `demo_a`.* TO 'app'@'%';"
+ );
+
+ fireEvent.click(screen.getByTestId('db-access-grant'));
+ const run = screen.getByTestId('db-access-confirm-run') as HTMLButtonElement;
+ expect(run.disabled).toBe(true);
+ fireEvent.change(screen.getByTestId('db-access-confirm-type'), { target: { value: 'app' } });
+ expect(run.disabled).toBe(true);
+ fireEvent.change(screen.getByTestId('db-access-confirm-type'), { target: { value: 'app@%' } });
+ expect(run.disabled).toBe(false);
+ fireEvent.click(run);
+ await waitFor(() => expect(executeSql).toHaveBeenCalled());
+ expect((executeSql.mock.calls[0][1] as string[]).join('\n')).toMatch(/ON `demo_a`\.\*/);
+ });
+
+ it('does not offer allow-all where the engine has none', async () => {
+ catalog('sqlite', [{ name: 'x', kind: 'user', canLogin: true, memberOf: [], members: [] }], []);
+ render( undefined} />);
+ fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
+ expect(screen.queryByText('All privileges (allow-all)')).toBeNull();
+ });
+});
diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx
index 4248bb9b..3839b822 100644
--- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx
+++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx
@@ -18,10 +18,16 @@ import {
X,
} from 'lucide-react';
import {
+ allPrivilegeTargets,
buildGrantRevokeSql,
+ describeAllowAll,
dialectSupportsDbAccess,
+ findAllowAll,
groupDbPrincipals,
+ groupPrivileges,
+ privilegeTargetLabel,
privilegesForPrincipal,
+ type AllowAll,
type DbPrincipal,
type DbPrivilege,
type DbPrivilegeObjectType,
@@ -53,9 +59,39 @@ type ConfirmAction = {
title: string;
sql: string;
kind: 'grant' | 'revoke';
+ /**
+ * The name the reader must type before Run is enabled. Set only for an
+ * allow-all grant, where one mis-click hands over the whole server.
+ */
+ typeToConfirm?: string;
+ /** Said above the SQL when set: what running it really does. */
+ note?: string;
};
const LS_CONN = 'foxschema-utilities-db-access-connection';
+/** The role picker's "type a name" choice — a value no role can have. */
+const OTHER_ROLE = '\u0000other';
+
+/** A shortened list of privilege names for a collapsed group. */
+function privilegeSummary(names: readonly string[]): string {
+ if (names.length <= 3) return names.join(', ');
+ 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 GRANT_PRIV_META = PERMISSION_META.find((m) => m.id === 'editor.grant');
export const DatabaseAccessModal: React.FC = ({
@@ -84,6 +120,8 @@ export const DatabaseAccessModal: React.FC = ({
const [filter, setFilter] = useState('');
const [selectedName, setSelectedName] = useState(null);
const [expandedGroups, setExpandedGroups] = useState>(() => new Set(['role', 'user']));
+ /** Privilege groups (per object) the reader has opened to see each privilege. */
+ const [openPrivGroups, setOpenPrivGroups] = useState>(() => new Set());
const [confirm, setConfirm] = useState(null);
const loadToken = useRef(0);
@@ -92,8 +130,10 @@ export const DatabaseAccessModal: React.FC = ({
const [grantSchema, setGrantSchema] = useState('');
const [grantName, setGrantName] = useState('');
const [grantWithOption, setGrantWithOption] = useState(false);
- /** Object grants use the sectioned UX; this toggle is only for role membership. */
- const [grantKind, setGrantKind] = useState<'sections' | 'membership'>('sections');
+ /** Object grants use the sectioned UX; membership and allow-all have their own forms. */
+ const [grantKind, setGrantKind] = useState<'sections' | 'membership' | 'all'>('sections');
+ const [allTargetId, setAllTargetId] = useState('');
+ const [confirmTyped, setConfirmTyped] = useState('');
const conn = connections.find((c) => c.id === connectionId);
// File dialects carry no password; asking for one blocked the utility outright.
@@ -154,18 +194,38 @@ export const DatabaseAccessModal: React.FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, connectionId, needsPassword]);
+ /**
+ * 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 groups = useMemo(() => {
const q = filter.trim().toLowerCase();
+ // "allow-all" and "superuser" find the accounts that are allowed
+ // everything — the ones a reader most needs to find, and could not.
+ const allowAllQuery = q === 'allow-all' || q === 'superuser';
const filtered = q
- ? principals.filter(
- (p) =>
+ ? principals.filter((p) => {
+ const allow = allowAllByName.get(p.name);
+ if (allowAllQuery) return q === 'superuser' ? allow?.kind === 'superuser' : Boolean(allow);
+ return (
p.name.toLowerCase().includes(q) ||
p.memberOf.some((m) => m.toLowerCase().includes(q)) ||
p.members.some((m) => m.toLowerCase().includes(q))
- )
+ );
+ })
: principals;
return groupDbPrincipals(filtered);
- }, [principals, filter]);
+ }, [principals, filter, allowAllByName]);
const selected = principals.find((p) => p.name === selectedName) ?? null;
const allSelectedPrivs = selected ? privilegesForPrincipal(privileges, selected.name) : [];
@@ -174,6 +234,45 @@ export const DatabaseAccessModal: React.FC = ({
// sections below from reading as one.
const selectedPrivs = allSelectedPrivs.filter((p) => p.objectType !== 'ROLE');
const selectedMemberships = allSelectedPrivs.filter((p) => p.objectType === 'ROLE');
+ const selectedPrivGroups = groupPrivileges(selectedPrivs, dialect);
+ const selectedAllowAll = selected ? allowAllByName.get(selected.name) ?? null : null;
+ /**
+ * Roles and groups the selected principal could be added to: every one the
+ * catalog listed, less itself and the ones it already belongs to. A typed
+ * name is still possible, for a catalog the login may only partly read.
+ */
+ const roleChoices = useMemo(
+ () =>
+ selected
+ ? principals
+ .filter((p) => p.kind !== 'user' && p.name !== selected.name)
+ .filter((p) => !selected.memberOf.some((m) => m.toLowerCase() === p.name.toLowerCase()))
+ .map((p) => p.name)
+ : [],
+ [principals, selected]
+ );
+ const [roleChoice, setRoleChoice] = useState('');
+ const typingRole = roleChoices.length === 0 || roleChoice === OTHER_ROLE;
+ useEffect(() => {
+ // A new principal means a new list; start on its first role, not a stale one.
+ 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) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ };
const grantPreview = useMemo(() => {
if (!dialect || !selected || grantKind !== 'membership') return null;
@@ -200,6 +299,31 @@ export const DatabaseAccessModal: React.FC = ({
conn?.schema,
]);
+ /** The allow-all grants this engine has, for the "All privileges" kind. */
+ const allTargets = useMemo(
+ () => (dialect ? allPrivilegeTargets(dialect, { database: conn?.database, schema: conn?.schema }) : []),
+ [dialect, conn?.database, conn?.schema]
+ );
+ const allTarget = allTargets.find((t) => t.id === allTargetId) ?? allTargets[0] ?? null;
+ const allPreview = useMemo(() => {
+ if (!dialect || !selected || grantKind !== 'all' || !allTarget) return null;
+ return buildGrantRevokeSql({
+ dialect,
+ action: 'grant',
+ privilege: allTarget.privilege,
+ objectType: allTarget.objectType,
+ objectSchema: allTarget.objectSchema,
+ objectName: allTarget.objectName,
+ grantee: selected.name,
+ granteeKind: granteeKindOf(selected),
+ });
+ }, [dialect, selected, grantKind, allTarget]);
+ useEffect(() => {
+ // A new confirmation starts empty: a name typed for the last one must not
+ // arm this one.
+ setConfirmTyped('');
+ }, [confirm]);
+
const runSql = async (sql: string, kind: 'grant' | 'revoke') => {
if (!connectionId || !canGrant) return;
setRunning(true);
@@ -406,6 +530,12 @@ 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'}
@@ -449,6 +579,15 @@ export const DatabaseAccessModal: React.FC = ({
)}
+ {selectedAllowAll && (
+
+ {describeAllowAll(selectedAllowAll)}
+
+ )}
+
Object privileges ({selectedPrivs.length})
@@ -468,51 +607,138 @@ export const DatabaseAccessModal: React.FC
= ({
- {selectedPrivs.map((priv, i) => {
- const on =
- [priv.objectSchema, priv.objectName].filter(Boolean).join('.') ||
- priv.objectType;
+ {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 (
-
-
- {priv.state === 'deny' ? 'DENY ' : ''}
- {priv.privilege}
- {priv.grantable ? ' *' : ''}
-
- {on}
-
- {
- const built = buildGrantRevokeSql({
- dialect,
- action: 'revoke',
- privilege: priv.privilege,
- objectType: priv.objectType,
- 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: 'Revoke privilege', sql: built.sql, kind: 'revoke' });
- }}
- className="text-[10px] font-bold uppercase tracking-wide text-rose-300 hover:text-rose-100 disabled:opacity-40"
+
+ {!single && (
+
- Revoke
-
-
-
+
+ togglePrivGroup(group.key)}
+ aria-expanded={expanded}
+ className="inline-flex items-center gap-1 text-left font-semibold hover:text-slate-50"
+ title={names.join(', ')}
+ >
+
+ {deny}
+ {group.all ? 'ALL PRIVILEGES' : privilegeSummary(names)}
+
+
+ ({group.privileges.length})
+
+
+ {on}
+
+ {group.all && !implied && group.state !== 'deny' && (
+
+ Revoke all
+
+ )}
+
+
+ )}
+ {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' && (
+ {
+ const built = buildGrantRevokeSql({
+ dialect,
+ action: 'revoke',
+ privilege: priv.privilege,
+ objectType: priv.objectType,
+ objectSchema: priv.objectSchema,
+ objectName: priv.objectName,
+ grantee: selected.name,
+ granteeKind: granteeKindOf(selected),
+ });
+ if ('error' in built) {
+ setError(built.error);
+ return;
+ }
+ setConfirm({ title: 'Revoke privilege', sql: built.sql, kind: 'revoke' });
+ }}
+ className="text-[10px] font-bold uppercase tracking-wide text-rose-300 hover:text-rose-100 disabled:opacity-40"
+ >
+ Revoke
+
+ )}
+
+
+ );
+ })}
+
);
})}
@@ -522,9 +748,10 @@ export const DatabaseAccessModal: React.FC = ({
{selectedPrivs.length > 0 && (
- * may pass the privilege on to
- others (WITH GRANT OPTION). DENY {' '}
- overrides any grant of the same privilege.
+ grantable may pass the
+ privilege on to others (WITH GRANT OPTION).{' '}
+ DENY overrides any grant of the same
+ privilege.
)}
@@ -631,7 +858,7 @@ export const DatabaseAccessModal: React.FC = ({
data-testid="db-access-grant-kind"
value={grantKind}
onChange={(e) => {
- const v = e.target.value as 'sections' | 'membership';
+ const v = e.target.value as 'sections' | 'membership' | 'all';
setGrantKind(v);
setGrantObjectType(v === 'membership' ? 'ROLE' : 'TABLE');
}}
@@ -639,6 +866,7 @@ export const DatabaseAccessModal: React.FC = ({
>
Object / schema privileges
Membership of a role
+ {allTargets.length > 0 && All privileges (allow-all) }
@@ -662,18 +890,100 @@ export const DatabaseAccessModal: React.FC = ({
/>
)}
- {grantKind === 'membership' && (
- <>
+ {grantKind === 'all' && allTarget && (
+
- Role
- setGrantName(e.target.value)}
- placeholder="role name"
- className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono"
- />
+ Everything on
+ setAllTargetId(e.target.value)}
+ className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-slate-100"
+ >
+ {allTargets.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ Critical ·
+ {allTarget.note}
+
+ {allPreview && 'sql' in allPreview && (
+
+ {allPreview.sql}
+
+ )}
+ {allPreview && 'error' in allPreview && (
+
{allPreview.error}
+ )}
+
{
+ if (!allPreview || 'error' in allPreview || !selected) return;
+ setConfirm({
+ title: 'Grant all privileges',
+ sql: allPreview.sql,
+ kind: 'grant',
+ typeToConfirm: selected.name,
+ note: allTarget.note,
+ });
+ }}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-md border border-rose-500/50 bg-rose-500/20 text-rose-50 hover:bg-rose-500/30 disabled:opacity-40"
+ >
+
+ Grant all
+
+
+ )}
+
+ {grantKind === 'membership' && (
+ <>
+ {roleChoices.length > 0 && (
+
+ Role
+ {
+ setRoleChoice(e.target.value);
+ if (e.target.value === OTHER_ROLE) setGrantName('');
+ }}
+ className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono"
+ >
+ {roleChoices.map((name) => (
+
+ {name}
+
+ ))}
+ Other… (type a name)
+
+
+ )}
+ {typingRole && (
+
+ {roleChoices.length > 0 ? 'Role name' : 'Role'}
+ setGrantName(e.target.value)}
+ placeholder="role name"
+ className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono"
+ />
+
+ )}
= ({
data-testid="db-access-confirm"
>
{confirm.title}
+ {confirm.note && {confirm.note}
}
{confirm.sql}
+ {confirm.typeToConfirm && (
+
+ Type {confirm.typeToConfirm} to confirm
+ setConfirmTyped(e.target.value)}
+ className="bg-slate-950 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono"
+ />
+
+ )}
= ({
{
canLogin: false,
memberOf: [],
members: ['alice', 'bob'],
+ superuser: null,
},
{
name: 'alice',
@@ -102,6 +105,7 @@ describe('normalizeDbPrincipals / privileges', () => {
canLogin: true,
memberOf: ['analysts'],
members: [],
+ superuser: null,
},
]);
expect(groupDbPrincipals(principals).map((g) => [g.kind, g.principals.length])).toEqual([
@@ -450,3 +454,258 @@ describe('principalsFromPrivileges', () => {
expect(principalsFromPrivileges([priv(''), priv(' ')])).toEqual([]);
});
});
+
+describe('roles and allow-all in the catalog queries', () => {
+ it('tells a MySQL role from a user by its locked, passwordless row', () => {
+ const [first, noRoles] = buildDbAccessPrincipalQueries({ dialect: 'mysql' });
+ expect(first!.sql).toMatch(/account_locked = 'Y'.*authentication_string/s);
+ expect(first!.sql).toMatch(/THEN 'role' ELSE 'user' END AS kind/);
+ // MySQL 5.7 has no role_edges; the next probe does not need it.
+ expect(noRoles!.sql).not.toMatch(/role_edges/);
+ });
+
+ it('names a MariaDB role bare, as MariaDB does', () => {
+ const [first] = buildDbAccessPrincipalQueries({ dialect: 'mariadb' });
+ expect(first!.sql).toMatch(/is_role = 'Y' THEN u\.User ELSE CONCAT/);
+ });
+
+ it('reads MySQL-family global grants and memberships, dropping USAGE', () => {
+ for (const dialect of ['mysql', 'tidb', 'mariadb']) {
+ const [full, noRoles, legacy] = buildDbAccessPrivilegeQueries({ dialect });
+ expect(full!.sql, dialect).toMatch(/USER_PRIVILEGES\s+WHERE PRIVILEGE_TYPE <> 'USAGE'/);
+ expect(full!.sql, dialect).toMatch(dialect === 'mariadb' ? /roles_mapping/ : /role_edges/);
+ expect(noRoles!.sql, dialect).toMatch(/USER_PRIVILEGES/);
+ expect(noRoles!.sql, dialect).not.toMatch(/mysql\./);
+ expect(legacy!.sql, dialect).not.toMatch(/USER_PRIVILEGES/);
+ }
+ });
+
+ it('binds the schema filter to the two filtered selects only', () => {
+ const q = buildDbAccessPrivilegeQueries({ dialect: 'mysql', schema: 'demo_a' });
+ expect(q).toHaveLength(6);
+ expect(q[0]!.params).toEqual(['demo_a', 'demo_a']);
+ expect(q[0]!.sql.match(/\?/g)).toHaveLength(2);
+ expect(q[3]!.params).toEqual([]);
+ });
+
+ it('reads Postgres superuser and schema/database ACLs, with fallbacks', () => {
+ const [pr, prOld] = buildDbAccessPrincipalQueries({ dialect: 'postgres' });
+ expect(pr!.sql).toMatch(/rolsuper AS superuser/);
+ expect(prOld!.sql).not.toMatch(/rolsuper/);
+ const [pv, pvOld] = buildDbAccessPrivilegeQueries({ dialect: 'postgres' });
+ expect(pv!.sql).toMatch(/aclexplode\(n\.nspacl\)/);
+ expect(pv!.sql).toMatch(/aclexplode\(d\.datacl\)/);
+ // The dead branch: usage_privileges never holds a SCHEMA row.
+ expect(pv!.sql).not.toMatch(/usage_privileges/);
+ expect(pvOld!.sql).toMatch(/role_table_grants/);
+ });
+
+ it('reads ClickHouse memberships and calls *.* GLOBAL, not SYSTEM', () => {
+ const [pr] = buildDbAccessPrincipalQueries({ dialect: 'clickhouse' });
+ expect(pr!.sql).toMatch(/system\.role_grants/);
+ const [pv] = buildDbAccessPrivilegeQueries({ dialect: 'clickhouse' });
+ expect(pv!.sql).toMatch(/'GLOBAL'\) AS object_type/);
+ expect(pv!.sql).not.toMatch(/'SYSTEM'/);
+ expect(pv!.sql).toMatch(/FROM system\.role_grants/);
+ });
+
+ it('lists Db2 groups', () => {
+ const [pr] = buildDbAccessPrincipalQueries({ dialect: 'db2' });
+ expect(pr!.sql).toMatch(/'group' AS kind/);
+ expect(pr!.sql).toMatch(/GRANTEETYPE = 'G'/);
+ });
+
+ it('asks SQL Server about sysadmin, with a database-only fallback', () => {
+ const [pr, fallback] = buildDbAccessPrincipalQueries({ dialect: 'sqlserver' });
+ expect(pr!.sql).toMatch(/IS_SRVROLEMEMBER\('sysadmin'/);
+ expect(fallback!.sql).not.toMatch(/IS_SRVROLEMEMBER/);
+ });
+});
+
+describe('normalizing MySQL-family grantees', () => {
+ it("reads 'user'@'host' as user@host and a MariaDB role 'r'@'' as r", () => {
+ const out = normalizeDbPrivileges([
+ { grantee: "'zz_app'@'%'", privilege: 'SELECT', object_type: 'GLOBAL' },
+ { grantee: "'zz_reader'@''", privilege: 'SELECT', object_type: 'SCHEMA', object_schema: 'demo_a' },
+ { grantee: "'o''brien'@'%'", privilege: 'SELECT', object_type: 'TABLE' },
+ ]);
+ expect(out.map((p) => p.grantee)).toEqual(['zz_app@%', 'zz_reader', "o'brien@%"]);
+ expect(out[0]!.objectType).toBe('GLOBAL');
+ });
+
+ it('reads a superuser flag', () => {
+ const [p] = normalizeDbPrincipals([{ name: 'admin', kind: 'user', rolsuper: true }]);
+ expect(p!.superuser).toBe(true);
+ });
+});
+
+describe('GRANT and REVOKE for roles and *.*', () => {
+ it('revokes a global MySQL grant ON *.*', () => {
+ const built = buildGrantRevokeSql({
+ dialect: 'mysql',
+ action: 'revoke',
+ privilege: 'ALL',
+ objectType: 'GLOBAL',
+ grantee: 'zz_app@%',
+ });
+ expect(built).toEqual({ sql: "REVOKE ALL PRIVILEGES ON *.* FROM 'zz_app'@'%';" });
+ });
+
+ it('refuses *.* where the engine has no such grant', () => {
+ const built = buildGrantRevokeSql({
+ dialect: 'postgres',
+ action: 'grant',
+ privilege: 'SELECT',
+ objectType: 'GLOBAL',
+ grantee: 'alice',
+ });
+ expect(built).toHaveProperty('error');
+ });
+
+ it('names a MySQL role as an account in GRANT role TO user', () => {
+ const built = buildGrantRevokeSql({
+ dialect: 'mysql',
+ action: 'grant',
+ privilege: 'zz_reader@%',
+ objectType: 'ROLE',
+ objectName: 'zz_reader@%',
+ grantee: 'zz_app@%',
+ });
+ // Backticks would name a role literally called `zz_reader@%`.
+ expect(built).toEqual({ sql: "GRANT 'zz_reader'@'%' TO 'zz_app'@'%';" });
+ });
+
+ it('names a MariaDB role with no host, as grantee and as role', () => {
+ expect(formatDbGrantee('mariadb', 'zz_reader', 'role')).toBe("'zz_reader'");
+ const built = buildGrantRevokeSql({
+ dialect: 'mariadb',
+ action: 'revoke',
+ privilege: 'zz_reader',
+ objectType: 'ROLE',
+ objectName: 'zz_reader',
+ grantee: 'zz_app@%',
+ });
+ expect(built).toEqual({ sql: "REVOKE 'zz_reader' FROM 'zz_app'@'%';" });
+ });
+});
+
+describe('reconcileDbAccess', () => {
+ const principal = (name: string, extra: Partial = {}): DbPrincipal => ({
+ name,
+ kind: 'user',
+ canLogin: true,
+ memberOf: [],
+ members: [],
+ ...extra,
+ });
+
+ it('adds a ROLE row for a membership only the principal catalog reported', () => {
+ // MySQL before this fix: member_of filled, no ROLE row, so the detail pane
+ // said "Belongs to no roles" under a list that said otherwise.
+ const out = reconcileDbAccess({
+ dialect: 'mysql',
+ principals: [
+ principal('zz_app@%', { memberOf: ['zz_reader@%'] }),
+ principal('zz_reader@%', { kind: 'role', members: ['zz_app@%'] }),
+ ],
+ privileges: [],
+ });
+ const roles = out.privileges.filter((p) => p.objectType === 'ROLE');
+ expect(roles).toEqual([
+ expect.objectContaining({
+ grantee: 'zz_app@%',
+ objectName: 'zz_reader@%',
+ source: 'derived',
+ }),
+ ]);
+ });
+
+ it('reads a membership from either side on its own', () => {
+ // MariaDB before this fix filled member_of and left members empty; other
+ // catalogs fill only a role's members. Each side alone is enough.
+ const fromMemberOf = reconcileDbAccess({
+ dialect: 'mariadb',
+ principals: [principal('app@%', { memberOf: ['reader'] }), principal('reader', { kind: 'role' })],
+ privileges: [],
+ });
+ const fromMembers = reconcileDbAccess({
+ dialect: 'mariadb',
+ principals: [principal('app@%'), principal('reader', { kind: 'role', members: ['app@%'] })],
+ privileges: [],
+ });
+ for (const out of [fromMemberOf, fromMembers]) {
+ expect(out.privileges.map((p) => [p.grantee, p.objectName])).toEqual([['app@%', 'reader']]);
+ expect(out.principals[0]!.memberOf).toEqual(['reader']);
+ expect(out.principals[1]!.members).toEqual(['app@%']);
+ }
+ });
+
+ it('fills memberOf and members from ROLE rows only the privilege catalog reported', () => {
+ const out = reconcileDbAccess({
+ dialect: 'postgres',
+ principals: [principal('alice'), principal('reader', { kind: 'role', canLogin: false })],
+ privileges: [
+ {
+ grantee: 'alice',
+ privilege: 'reader',
+ objectType: 'ROLE',
+ objectSchema: null,
+ objectName: 'reader',
+ grantable: false,
+ grantor: null,
+ state: null,
+ },
+ ],
+ });
+ expect(out.principals[0]!.memberOf).toEqual(['reader']);
+ expect(out.principals[1]!.members).toEqual(['alice']);
+ // Nothing duplicated: the row was already there.
+ expect(out.privileges).toHaveLength(1);
+ });
+
+ it('matches quoted grantees against principal names', () => {
+ const out = reconcileDbAccess({
+ dialect: 'mysql',
+ principals: [principal('zz_app@%', { memberOf: ['zz_reader@%'] })],
+ privileges: normalizeDbPrivileges([
+ { grantee: "'zz_app'@'%'", privilege: 'zz_reader@%', object_type: 'ROLE', object_name: 'zz_reader@%' },
+ ]),
+ });
+ expect(out.privileges.filter((p) => p.objectType === 'ROLE')).toHaveLength(1);
+ });
+
+ it("gives SQL Server's fixed roles the permissions they imply", () => {
+ const out = reconcileDbAccess({
+ dialect: 'sqlserver',
+ principals: [principal('db_owner', { kind: 'role' }), principal('db_datareader', { kind: 'role' })],
+ privileges: [],
+ });
+ expect(out.privileges.map((p) => [p.grantee, p.privilege, p.objectType, p.source])).toEqual([
+ ['db_owner', 'CONTROL', 'DATABASE', 'implied'],
+ ['db_datareader', 'SELECT', 'DATABASE', 'implied'],
+ ]);
+ });
+
+ it('does not change its inputs', () => {
+ const input = [principal('alice', { memberOf: ['r'] })];
+ reconcileDbAccess({ dialect: 'postgres', principals: input, privileges: [] });
+ expect(input[0]!.memberOf).toEqual(['r']);
+ });
+});
+
+describe('role membership WITH ADMIN OPTION', () => {
+ it('passes the checkbox on as ADMIN OPTION, and never on SQL Server', () => {
+ const grant = (dialect: string) =>
+ buildGrantRevokeSql({
+ dialect,
+ action: 'grant',
+ privilege: 'reader',
+ objectType: 'ROLE',
+ objectName: 'reader',
+ grantee: 'alice',
+ withGrantOption: true,
+ });
+ expect(grant('postgres')).toEqual({ sql: 'GRANT "reader" TO "alice" WITH ADMIN OPTION;' });
+ expect(grant('sqlserver')).toEqual({ sql: 'ALTER ROLE [reader] ADD MEMBER [alice];' });
+ });
+});
diff --git a/packages/sql/src/modules/access/db-access.ts b/packages/sql/src/modules/access/db-access.ts
index 2cbb955a..771ff2ee 100644
--- a/packages/sql/src/modules/access/db-access.ts
+++ b/packages/sql/src/modules/access/db-access.ts
@@ -38,9 +38,29 @@ export interface DbPrincipal {
memberOf: string[];
/** Members when this principal is a role/group. */
members: string[];
+ /**
+ * Bypasses every permission check (Postgres `rolsuper`, SQL Server
+ * `sysadmin`). An account attribute, not a grant, so it never appears in the
+ * privilege list — which is how a superuser came to read as "no privileges".
+ * Absent or null where the engine has no such flag or the catalog does not say.
+ */
+ superuser?: boolean | null;
}
-export type DbPrivilegeObjectType = 'TABLE' | 'SCHEMA' | 'DATABASE' | 'ROLE' | 'SYSTEM' | 'COLUMN' | 'OTHER';
+/**
+ * `GLOBAL` is instance-wide: MySQL-family and ClickHouse `ON *.*`. `SYSTEM` is
+ * an engine authority that takes no object (Oracle system privileges, Db2
+ * database authorities).
+ */
+export type DbPrivilegeObjectType =
+ | 'TABLE'
+ | 'SCHEMA'
+ | 'DATABASE'
+ | 'GLOBAL'
+ | 'ROLE'
+ | 'SYSTEM'
+ | 'COLUMN'
+ | 'OTHER';
export interface DbPrivilege {
grantee: string;
@@ -52,6 +72,12 @@ export interface DbPrivilege {
grantor: string | null;
/** SQL Server DENY vs GRANT. Null on engines without DENY. */
state: 'grant' | 'deny' | null;
+ /**
+ * Absent for a row the catalog returned. `derived`: a role membership filled
+ * in from the other catalog (see `reconcileDbAccess`). `implied`: what a
+ * fixed role confers without any row saying so (SQL Server `db_owner`).
+ */
+ source?: 'derived' | 'implied';
}
export interface DbAccessGrantArgs {
@@ -95,7 +121,7 @@ const SUPPORT: Record = {
mode: 'native',
query: true,
grant: true,
- hint: 'PostgreSQL: pg_roles / pg_auth_members and information_schema table/schema grants.',
+ hint: 'PostgreSQL: pg_roles (with superuser) / pg_auth_members, table grants, and schema and database ACLs.',
},
cockroachdb: {
mode: 'native',
@@ -119,13 +145,13 @@ const SUPPORT: Record = {
mode: 'native',
query: true,
grant: true,
- hint: 'MySQL: mysql.user / mysql.role_edges and INFORMATION_SCHEMA TABLE/SCHEMA_PRIVILEGES.',
+ hint: 'MySQL: mysql.user / mysql.role_edges and INFORMATION_SCHEMA USER/SCHEMA/TABLE_PRIVILEGES (including *.*).',
},
mariadb: {
mode: 'native',
query: true,
grant: true,
- hint: 'MariaDB: mysql.user / mysql.roles_mapping and INFORMATION_SCHEMA TABLE/SCHEMA_PRIVILEGES.',
+ hint: 'MariaDB: mysql.user / mysql.roles_mapping and INFORMATION_SCHEMA USER/SCHEMA/TABLE_PRIVILEGES (including *.*).',
},
tidb: {
mode: 'native',
@@ -137,7 +163,7 @@ const SUPPORT: Record = {
mode: 'native',
query: true,
grant: true,
- hint: 'SQL Server: sys.database_principals, database_role_members, database_permissions.',
+ hint: 'SQL Server: sys.database_principals (with sysadmin), database_role_members, database_permissions, and what fixed roles imply.',
},
azuresql: {
mode: 'native',
@@ -161,7 +187,7 @@ const SUPPORT: Record = {
mode: 'native',
query: true,
grant: true,
- hint: 'ClickHouse: system.users, system.roles, system.grants.',
+ hint: 'ClickHouse: system.users, system.roles, system.role_grants, system.grants.',
},
sqlite: UNSUPPORTED,
duckdb: UNSUPPORTED,
@@ -182,16 +208,31 @@ export function buildDbAccessPrincipalQueries(opts: {
schema?: string;
}): DbAccessQuery[] {
const fam = family(opts.dialect);
- if (fam === 'postgres') return [{ sql: PG_PRINCIPALS, params: [] }];
- if (fam === 'mysql') return [{ sql: MYSQL_PRINCIPALS, params: [] }, { sql: MYSQL_PRINCIPALS_FALLBACK, params: [] }];
+ if (fam === 'postgres') {
+ // rolsuper is in every Postgres-family pg_roles we target; the second probe
+ // keeps an engine without it listing its roles rather than nothing.
+ return [{ sql: PG_PRINCIPALS, params: [] }, { sql: PG_PRINCIPALS_NO_SUPERUSER, params: [] }];
+ }
+ if (fam === 'mysql') {
+ return [
+ { sql: MYSQL_PRINCIPALS, params: [] },
+ // MySQL 5.7 has no mysql.role_edges, and so no roles.
+ { sql: MYSQL_PRINCIPALS_NO_ROLES, params: [] },
+ { sql: MYSQL_PRINCIPALS_FALLBACK, params: [] },
+ ];
+ }
if (fam === 'mariadb') {
return [
{ sql: MARIADB_PRINCIPALS, params: [] },
- { sql: MYSQL_PRINCIPALS, params: [] },
+ { sql: MYSQL_PRINCIPALS_NO_ROLES, params: [] },
{ sql: MYSQL_PRINCIPALS_FALLBACK, params: [] },
];
}
- if (fam === 'sqlserver') return [{ sql: MSSQL_PRINCIPALS, params: [] }];
+ if (fam === 'sqlserver') {
+ // Azure SQL Database, or a login that may not ask about server roles, falls
+ // back to the database-only form.
+ return [{ sql: MSSQL_PRINCIPALS, params: [] }, { sql: MSSQL_PRINCIPALS_NO_SERVER, params: [] }];
+ }
if (fam === 'oracle') {
return [{ sql: ORACLE_PRINCIPALS_DBA, params: [] }, { sql: ORACLE_PRINCIPALS_ALL, params: [] }];
}
@@ -201,7 +242,12 @@ export function buildDbAccessPrincipalQueries(opts: {
{ sql: DB2_PRINCIPALS_ROLES, params: [] },
];
}
- if (fam === 'clickhouse') return [{ sql: CLICKHOUSE_PRINCIPALS, params: [] }];
+ if (fam === 'clickhouse') {
+ return [
+ { sql: CLICKHOUSE_PRINCIPALS, params: [] },
+ { sql: CLICKHOUSE_PRINCIPALS_NO_ROLE_GRANTS, params: [] },
+ ];
+ }
return [{ sql: GENERIC_PRINCIPALS, params: [] }];
}
@@ -211,11 +257,23 @@ export function buildDbAccessPrivilegeQueries(opts: {
}): DbAccessQuery[] {
const fam = family(opts.dialect);
const schema = (opts.schema ?? '').trim();
- if (fam === 'postgres') return [{ sql: PG_PRIVILEGES, params: [] }];
+ if (fam === 'postgres') {
+ // aclexplode reads schema and database ACLs; an engine without it still
+ // gets table grants and memberships from the information_schema form.
+ return [{ sql: PG_PRIVILEGES, params: [] }, { sql: PG_PRIVILEGES_NO_ACL, params: [] }];
+ }
if (fam === 'mysql' || fam === 'mariadb') {
- return schema
- ? [{ sql: MYSQL_PRIVILEGES_SCHEMA, params: [schema, schema] }, { sql: MYSQL_PRIVILEGES, params: [] }]
- : [{ sql: MYSQL_PRIVILEGES, params: [] }];
+ // Most complete first. A login that may not read mysql.* loses the role
+ // rows only; global grants come from information_schema, which every
+ // login may read (it shows each its own).
+ const roles = fam === 'mariadb' ? 'mariadb' : 'mysql';
+ const ladder = (filter: boolean): DbAccessQuery[] =>
+ [
+ mysqlPrivilegesQuery({ schemaFilter: filter, global: true, roles }),
+ mysqlPrivilegesQuery({ schemaFilter: filter, global: true, roles: null }),
+ mysqlPrivilegesQuery({ schemaFilter: filter, global: false, roles: null }),
+ ].map((sql) => ({ sql, params: filter ? [schema, schema] : [] }));
+ return schema ? [...ladder(true), ...ladder(false)] : ladder(false);
}
if (fam === 'sqlserver') return [{ sql: MSSQL_PRIVILEGES, params: [] }];
if (fam === 'oracle') {
@@ -241,6 +299,7 @@ export function normalizeDbPrincipals(rows: ReadonlyArray a.name.localeCompare(b.name));
@@ -323,6 +383,104 @@ export function groupDbPrincipals(principals: readonly DbPrincipal[]): Array<{
];
}
+/**
+ * What SQL Server's fixed database roles confer with no permission row to say
+ * so. Without these, `db_owner` — allowed everything in the database — listed
+ * no privileges at all.
+ */
+const SQLSERVER_FIXED_ROLE_PRIVILEGES: Record = {
+ db_owner: ['CONTROL'],
+ db_datareader: ['SELECT'],
+ db_datawriter: ['INSERT', 'UPDATE', 'DELETE'],
+};
+
+function memberKey(name: string): string {
+ return stripQuotes(name).toLowerCase();
+}
+
+/**
+ * Make the two catalogs agree about role membership, and add what fixed roles
+ * imply.
+ *
+ * Membership arrives two ways: `memberOf` / `members` on each principal, and
+ * ROLE rows in the privilege list. Postgres, SQL Server and Oracle fill both;
+ * the MySQL family and ClickHouse used to fill one, so the list said "member of
+ * reader" while the detail pane said "Belongs to no roles". Each side is filled
+ * from the other here, once, so every screen reads the same answer. Rows added
+ * this way carry `source: 'derived'`.
+ */
+export function reconcileDbAccess(opts: {
+ dialect: string;
+ principals: readonly DbPrincipal[];
+ privileges: readonly DbPrivilege[];
+}): { principals: DbPrincipal[]; privileges: DbPrivilege[] } {
+ const principals = opts.principals.map((p) => ({
+ ...p,
+ memberOf: [...p.memberOf],
+ members: [...p.members],
+ }));
+ const byName = new Map(principals.map((p) => [memberKey(p.name), p]));
+ const privileges = [...opts.privileges];
+ const edges = new Set();
+ const edgeKey = (member: string, role: string) => `${memberKey(member)}\u0000${memberKey(role)}`;
+
+ for (const priv of privileges) {
+ if (priv.objectType !== 'ROLE') continue;
+ edges.add(edgeKey(priv.grantee, priv.objectName ?? priv.privilege));
+ }
+ const addEdge = (member: string, role: string) => {
+ const k = edgeKey(member, role);
+ if (edges.has(k)) return;
+ edges.add(k);
+ privileges.push({
+ grantee: member,
+ privilege: role,
+ objectType: 'ROLE',
+ objectSchema: null,
+ objectName: role,
+ grantable: false,
+ grantor: null,
+ state: null,
+ source: 'derived',
+ });
+ };
+ for (const p of principals) {
+ for (const role of p.memberOf) addEdge(p.name, role);
+ for (const member of p.members) addEdge(member, p.name);
+ }
+
+ const has = (list: string[], name: string) => list.some((n) => memberKey(n) === memberKey(name));
+ for (const priv of privileges) {
+ if (priv.objectType !== 'ROLE') continue;
+ const role = priv.objectName ?? priv.privilege;
+ const member = byName.get(memberKey(priv.grantee));
+ if (member && !has(member.memberOf, role)) member.memberOf.push(role);
+ const rolePrincipal = byName.get(memberKey(role));
+ if (rolePrincipal && !has(rolePrincipal.members, priv.grantee)) {
+ rolePrincipal.members.push(member?.name ?? stripQuotes(priv.grantee));
+ }
+ }
+
+ if (family(opts.dialect) === 'sqlserver') {
+ for (const p of principals) {
+ for (const privilege of SQLSERVER_FIXED_ROLE_PRIVILEGES[p.name.toLowerCase()] ?? []) {
+ privileges.push({
+ grantee: p.name,
+ privilege,
+ objectType: 'DATABASE',
+ objectSchema: null,
+ objectName: null,
+ grantable: false,
+ grantor: null,
+ state: 'grant',
+ source: 'implied',
+ });
+ }
+ }
+ }
+ return { principals, privileges };
+}
+
/** Quote a principal for GRANT/REVOKE (MySQL `'user'@'host'`, Db2 `USER "x"`). */
export function formatDbGrantee(
dialect: string,
@@ -337,6 +495,10 @@ export function formatDbGrantee(
// the quote early and turn the rest of the GRANT into free SQL. Double
// backslashes before doubling quotes.
const quote = (v: string) => `'${v.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
+ // A MariaDB role has no host: `'r'@'%'` names a user that does not exist.
+ if (fam === 'mariadb' && kind === 'role') {
+ return quote(raw.endsWith('@') ? raw.slice(0, -1) : raw);
+ }
const at = raw.lastIndexOf('@');
if (at > 0) {
return `${quote(raw.slice(0, at))}@${quote(raw.slice(at + 1))}`;
@@ -412,7 +574,12 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } |
if (objectType === 'ROLE') {
const roleName = (args.objectName || privilege).trim();
if (!roleName) return { error: 'role name is required.' };
- const roleSql = ident(roleName);
+ // A MySQL role is an account, `'r'@'%'`; the catalog names it `r@%`, and a
+ // backtick-quoted `r@%` is a role nobody created.
+ const roleSql =
+ fam === 'mysql' || fam === 'mariadb'
+ ? formatDbGrantee(args.dialect, roleName, 'role')
+ : ident(roleName);
// SQL Server does not grant a role, it adds a member to one.
if (fam === 'sqlserver') {
const member = ident(stripQuotes(grantee));
@@ -424,19 +591,26 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } |
};
}
// Db2 names the object kind; every other engine grants the role directly,
- // Oracle included — it had its own branch emitting exactly this.
+ // Oracle included — it had its own branch emitting exactly this. A role is
+ // passed on WITH ADMIN OPTION, not GRANT OPTION; the form's checkbox used
+ // to be dropped here without a word.
return emitGrant({
action,
privilege: fam === 'db2' ? `ROLE ${roleSql}` : roleSql,
on: '',
grantee: granteeSql,
+ grantOption: action === 'grant' && args.withGrantOption ? ' WITH ADMIN OPTION' : undefined,
});
}
- if (!objectSql && objectType !== 'SYSTEM' && objectType !== 'DATABASE') {
+ if (!objectSql && objectType !== 'SYSTEM' && objectType !== 'DATABASE' && objectType !== 'GLOBAL') {
return { error: 'object name is required.' };
}
+ if (objectType === 'GLOBAL' && fam !== 'mysql' && fam !== 'mariadb' && fam !== 'clickhouse') {
+ return { error: 'This engine has no instance-wide (*.*) grant.' };
+ }
+
// --- Object privileges: each family supplies only its ON clause ----------
if (fam === 'sqlserver') {
// SQL Server scopes with a securable prefix rather than a keyword.
@@ -455,9 +629,11 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } |
// MySQL names a whole database as `db`.* — a bare `db` is a table
// reference, so a database-level grant landed on a table of that name.
const target =
- objectType === 'DATABASE' || objectType === 'SCHEMA'
- ? `${objectSql || namedObject()}.*`
- : objectSql || '*.*';
+ objectType === 'GLOBAL'
+ ? '*.*'
+ : objectType === 'DATABASE' || objectType === 'SCHEMA'
+ ? `${objectSql || namedObject()}.*`
+ : objectSql || '*.*';
return emitGrant({
action,
privilege: privSql === 'ALL' ? 'ALL PRIVILEGES' : privSql,
@@ -469,10 +645,17 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } |
if (fam === 'clickhouse') {
// ClickHouse takes no object keyword; `db.table` or `db.*` is the target.
+ // A database is `db.*` here as on MySQL; a bare `db` names a table.
+ const target =
+ objectType === 'GLOBAL'
+ ? '*.*'
+ : objectType === 'DATABASE' || objectType === 'SCHEMA'
+ ? `${objectSql || namedObject()}.*`
+ : objectSql || '*.*';
return emitGrant({
action,
privilege: privSql,
- on: `ON ${objectSql || '*.*'}`,
+ on: `ON ${target}`,
grantee: granteeSql,
grantOption,
});
@@ -534,6 +717,7 @@ function normalizeKind(raw: unknown): DbPrincipalKind {
function normalizeObjectType(raw: unknown): DbPrivilegeObjectType {
const s = String(raw ?? '').toUpperCase();
+ if (s === 'GLOBAL') return 'GLOBAL';
if (s.includes('COLUMN') && !s.includes('OBJECT_OR_COLUMN')) return 'COLUMN';
if (s.includes('SCHEMA')) return 'SCHEMA';
if (s.includes('DATABASE')) return 'DATABASE';
@@ -580,6 +764,15 @@ function splitList(raw: string): string[] {
}
function stripQuotes(name: string): string {
+ // information_schema prints a MySQL-family grantee as 'user'@'host', and a
+ // MariaDB role as 'role'@'' — a role has no host, and its principal row is
+ // the bare name. Without this the role read as `role'@` and matched nothing.
+ const account = /^'((?:[^']|'')*)'@'((?:[^']|'')*)'$/.exec(name.trim());
+ if (account) {
+ const user = account[1]!.replace(/''/g, "'");
+ const host = account[2]!.replace(/''/g, "'");
+ return host ? `${user}@${host}` : user;
+ }
return name.replace(/^['"`\[]+/, '').replace(/['"`\]]+$/, '').replace(/'@'/g, '@');
}
@@ -588,6 +781,28 @@ function emptyToNull(s: string): string | null {
}
const PG_PRINCIPALS = `
+SELECT r.rolname AS name,
+ CASE WHEN r.rolcanlogin THEN 'user' ELSE 'role' END AS kind,
+ r.rolcanlogin AS can_login,
+ r.rolsuper AS superuser,
+ COALESCE((
+ SELECT string_agg(g.rolname, ',' ORDER BY g.rolname)
+ FROM pg_auth_members am
+ JOIN pg_roles g ON g.oid = am.roleid
+ WHERE am.member = r.oid
+ ), '') AS member_of,
+ COALESCE((
+ SELECT string_agg(m.rolname, ',' ORDER BY m.rolname)
+ FROM pg_auth_members am
+ JOIN pg_roles m ON m.oid = am.member
+ WHERE am.roleid = r.oid
+ ), '') AS members
+FROM pg_roles r
+WHERE r.rolname NOT LIKE 'pg\\_%'
+ORDER BY CASE WHEN r.rolcanlogin THEN 1 ELSE 0 END, r.rolname
+`.trim();
+
+const PG_PRINCIPALS_NO_SUPERUSER = `
SELECT r.rolname AS name,
CASE WHEN r.rolcanlogin THEN 'user' ELSE 'role' END AS kind,
r.rolcanlogin AS can_login,
@@ -608,6 +823,15 @@ WHERE r.rolname NOT LIKE 'pg\\_%'
ORDER BY CASE WHEN r.rolcanlogin THEN 1 ELSE 0 END, r.rolname
`.trim();
+/**
+ * Table grants, schema and database ACLs, and role memberships.
+ *
+ * Schema and database privileges come from the ACL columns. The earlier form
+ * read `information_schema.usage_privileges WHERE object_type = 'SCHEMA'`, a
+ * value that view never holds (it lists domains, collations, sequences and
+ * foreign servers), so `GRANT ALL ON SCHEMA` and `GRANT ALL ON DATABASE` both
+ * read as nothing. A NULL ACL is the owner-only default and yields no rows.
+ */
const PG_PRIVILEGES = `
SELECT grantee,
privilege_type AS privilege,
@@ -619,6 +843,52 @@ SELECT grantee,
FROM information_schema.role_table_grants
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
UNION ALL
+SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
+ a.privilege_type,
+ 'SCHEMA',
+ n.nspname,
+ NULL,
+ CASE WHEN a.is_grantable THEN 1 ELSE 0 END,
+ pg_get_userbyid(a.grantor)
+FROM pg_namespace n
+CROSS JOIN LATERAL aclexplode(n.nspacl) a
+WHERE n.nspname NOT LIKE 'pg\\_%' AND n.nspname <> 'information_schema'
+UNION ALL
+SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
+ a.privilege_type,
+ 'DATABASE',
+ NULL,
+ d.datname,
+ CASE WHEN a.is_grantable THEN 1 ELSE 0 END,
+ pg_get_userbyid(a.grantor)
+FROM pg_database d
+CROSS JOIN LATERAL aclexplode(d.datacl) a
+WHERE d.datname = current_database()
+UNION ALL
+SELECT m.rolname,
+ g.rolname,
+ 'ROLE',
+ NULL,
+ g.rolname,
+ CASE WHEN am.admin_option THEN 1 ELSE 0 END,
+ NULL
+FROM pg_auth_members am
+JOIN pg_roles g ON g.oid = am.roleid
+JOIN pg_roles m ON m.oid = am.member
+WHERE g.rolname NOT LIKE 'pg\\_%'
+`.trim();
+
+const PG_PRIVILEGES_NO_ACL = `
+SELECT grantee,
+ privilege_type AS privilege,
+ 'TABLE' AS object_type,
+ table_schema AS object_schema,
+ table_name AS object_name,
+ CASE WHEN is_grantable = 'YES' THEN 1 ELSE 0 END AS grantable,
+ grantor
+FROM information_schema.role_table_grants
+WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
+UNION ALL
SELECT grantee,
privilege_type,
'SCHEMA',
@@ -642,10 +912,16 @@ JOIN pg_roles m ON m.oid = am.member
WHERE g.rolname NOT LIKE 'pg\\_%'
`.trim();
+/**
+ * A MySQL role is a row of mysql.user like any account. `CREATE ROLE` makes it
+ * locked with no password, and that is how one is told apart — every row used
+ * to be labelled `user`, which emptied "Roles & groups" on MySQL and TiDB.
+ */
const MYSQL_PRINCIPALS = `
SELECT CONCAT(u.User, '@', u.Host) AS name,
- 'user' AS kind,
- 1 AS can_login,
+ CASE WHEN u.account_locked = 'Y' AND COALESCE(u.authentication_string, '') = ''
+ THEN 'role' ELSE 'user' END AS kind,
+ CASE WHEN u.account_locked = 'Y' THEN 0 ELSE 1 END AS can_login,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT CONCAT(e.FROM_USER, '@', e.FROM_HOST) ORDER BY e.FROM_USER)
FROM mysql.role_edges e
@@ -661,16 +937,36 @@ WHERE u.User <> ''
ORDER BY u.User, u.Host
`.trim();
+/**
+ * MariaDB marks a role with `is_role` and gives it an empty host, and a role is
+ * named without one everywhere (`GRANT r TO …`), so its row is the bare name.
+ * `roles_mapping` holds memberships and also a role's creator, who is granted
+ * it WITH ADMIN OPTION automatically.
+ */
const MARIADB_PRINCIPALS = `
-SELECT CONCAT(u.User, '@', u.Host) AS name,
- 'user' AS kind,
- 1 AS can_login,
+SELECT CASE WHEN u.is_role = 'Y' THEN u.User ELSE CONCAT(u.User, '@', u.Host) END AS name,
+ CASE WHEN u.is_role = 'Y' THEN 'role' ELSE 'user' END AS kind,
+ CASE WHEN u.is_role = 'Y' THEN 0 ELSE 1 END AS can_login,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT rm.Role ORDER BY rm.Role)
FROM mysql.roles_mapping rm
WHERE rm.User = u.User AND rm.Host = u.Host
), '') AS member_of,
- '' AS members
+ CASE WHEN u.is_role = 'Y' THEN COALESCE((
+ SELECT GROUP_CONCAT(DISTINCT CASE WHEN rm.Host = '' THEN rm.User
+ ELSE CONCAT(rm.User, '@', rm.Host) END
+ ORDER BY rm.User)
+ FROM mysql.roles_mapping rm
+ WHERE rm.Role = u.User
+ ), '') ELSE '' END AS members
+FROM mysql.user u
+WHERE u.User <> ''
+ORDER BY u.User, u.Host
+`.trim();
+
+const MYSQL_PRINCIPALS_NO_ROLES = `
+SELECT CONCAT(u.User, '@', u.Host) AS name, 'user' AS kind, 1 AS can_login,
+ '' AS member_of, '' AS members
FROM mysql.user u
WHERE u.User <> ''
ORDER BY u.User, u.Host
@@ -680,49 +976,108 @@ const MYSQL_PRINCIPALS_FALLBACK = `
SELECT CURRENT_USER() AS name, 'user' AS kind, 1 AS can_login, '' AS member_of, '' AS members
`.trim();
-const MYSQL_PRIVILEGES = `
-SELECT GRANTEE AS grantee,
+/**
+ * Table and schema grants, plus — the part that used to be missing — global
+ * `ON *.*` grants and role memberships.
+ *
+ * `GRANT ALL PRIVILEGES ON *.*` lives only in USER_PRIVILEGES, so an account
+ * holding every privilege on the server read as "No object privileges". USAGE
+ * there means "no privileges" and is left out.
+ */
+function mysqlPrivilegesQuery(opts: {
+ schemaFilter: boolean;
+ global: boolean;
+ roles: 'mysql' | 'mariadb' | null;
+}): string {
+ const where = (col: string) => (opts.schemaFilter ? `\nWHERE ${col} = ?` : '');
+ const parts = [
+ `SELECT GRANTEE AS grantee,
PRIVILEGE_TYPE AS privilege,
'TABLE' AS object_type,
TABLE_SCHEMA AS object_schema,
TABLE_NAME AS object_name,
CASE WHEN IS_GRANTABLE = 'YES' THEN 1 ELSE 0 END AS grantable,
NULL AS grantor
-FROM information_schema.TABLE_PRIVILEGES
-UNION ALL
-SELECT GRANTEE,
+FROM information_schema.TABLE_PRIVILEGES${where('TABLE_SCHEMA')}`,
+ `SELECT GRANTEE,
PRIVILEGE_TYPE,
'SCHEMA',
TABLE_SCHEMA,
NULL,
CASE WHEN IS_GRANTABLE = 'YES' THEN 1 ELSE 0 END,
NULL
-FROM information_schema.SCHEMA_PRIVILEGES
-`.trim();
-
-const MYSQL_PRIVILEGES_SCHEMA = `
-SELECT GRANTEE AS grantee,
- PRIVILEGE_TYPE AS privilege,
- 'TABLE' AS object_type,
- TABLE_SCHEMA AS object_schema,
- TABLE_NAME AS object_name,
- CASE WHEN IS_GRANTABLE = 'YES' THEN 1 ELSE 0 END AS grantable,
- NULL AS grantor
-FROM information_schema.TABLE_PRIVILEGES
-WHERE TABLE_SCHEMA = ?
-UNION ALL
-SELECT GRANTEE,
+FROM information_schema.SCHEMA_PRIVILEGES${where('TABLE_SCHEMA')}`,
+ ];
+ if (opts.global) {
+ parts.push(`SELECT GRANTEE,
PRIVILEGE_TYPE,
- 'SCHEMA',
- TABLE_SCHEMA,
+ 'GLOBAL',
+ NULL,
NULL,
CASE WHEN IS_GRANTABLE = 'YES' THEN 1 ELSE 0 END,
NULL
-FROM information_schema.SCHEMA_PRIVILEGES
-WHERE TABLE_SCHEMA = ?
-`.trim();
+FROM information_schema.USER_PRIVILEGES
+WHERE PRIVILEGE_TYPE <> 'USAGE'`);
+ }
+ if (opts.roles === 'mysql') {
+ // Quoted the way information_schema quotes a grantee, so one normalizer
+ // reads both. The role is named user@host, as its principal row is.
+ parts.push(`SELECT CONCAT('''', e.TO_USER, '''@''', e.TO_HOST, ''''),
+ CONCAT(e.FROM_USER, '@', e.FROM_HOST),
+ 'ROLE',
+ NULL,
+ CONCAT(e.FROM_USER, '@', e.FROM_HOST),
+ CASE WHEN e.WITH_ADMIN_OPTION = 'Y' THEN 1 ELSE 0 END,
+ NULL
+FROM mysql.role_edges e`);
+ } else if (opts.roles === 'mariadb') {
+ parts.push(`SELECT CONCAT('''', rm.User, '''@''', rm.Host, ''''),
+ rm.Role,
+ 'ROLE',
+ NULL,
+ rm.Role,
+ CASE WHEN rm.Admin_option = 'Y' THEN 1 ELSE 0 END,
+ NULL
+FROM mysql.roles_mapping rm`);
+ }
+ return parts.join('\nUNION ALL\n');
+}
+/**
+ * `superuser` is sysadmin membership of the login behind each database user.
+ * IS_SRVROLEMEMBER answers NULL when the caller may not see it, which is kept
+ * as "unknown" rather than read as "no".
+ */
const MSSQL_PRINCIPALS = `
+SELECT dp.name AS name,
+ CASE
+ WHEN dp.type IN ('R', 'A') THEN 'role'
+ WHEN dp.type = 'G' THEN 'group'
+ ELSE 'user'
+ END AS kind,
+ CASE WHEN dp.type IN ('S', 'U', 'E', 'X') THEN 1 ELSE 0 END AS can_login,
+ CASE WHEN dp.sid IS NULL OR dp.type NOT IN ('S', 'U', 'G', 'E', 'X') THEN NULL
+ ELSE IS_SRVROLEMEMBER('sysadmin', SUSER_SNAME(dp.sid)) END AS superuser,
+ ISNULL(STUFF((
+ SELECT ',' + r.name
+ FROM sys.database_role_members rm
+ JOIN sys.database_principals r ON r.principal_id = rm.role_principal_id
+ WHERE rm.member_principal_id = dp.principal_id
+ FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, ''), '') AS member_of,
+ ISNULL(STUFF((
+ SELECT ',' + m.name
+ FROM sys.database_role_members rm
+ JOIN sys.database_principals m ON m.principal_id = rm.member_principal_id
+ WHERE rm.role_principal_id = dp.principal_id
+ FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, ''), '') AS members
+FROM sys.database_principals dp
+WHERE dp.name IS NOT NULL
+ AND dp.type IN ('S', 'U', 'G', 'R', 'A', 'E', 'X')
+ AND dp.name NOT IN ('sys', 'INFORMATION_SCHEMA')
+ORDER BY CASE WHEN dp.type IN ('R', 'A', 'G') THEN 0 ELSE 1 END, dp.name
+`.trim();
+
+const MSSQL_PRINCIPALS_NO_SERVER = `
SELECT dp.name AS name,
CASE
WHEN dp.type IN ('R', 'A') THEN 'role'
@@ -848,6 +1203,28 @@ FROM (
AND TRIM(GRANTEE) NOT LIKE 'SYS%'
) U
UNION ALL
+SELECT TRIM(G.GRANTEE) AS name,
+ 'group' AS kind,
+ 0 AS can_login,
+ COALESCE((
+ SELECT LISTAGG(TRIM(A.ROLENAME), ',') WITHIN GROUP (ORDER BY A.ROLENAME)
+ FROM SYSCAT.ROLEAUTH A
+ WHERE A.GRANTEETYPE = 'G'
+ AND TRIM(A.GRANTEE) = TRIM(G.GRANTEE)
+ AND A.ROLENAME NOT LIKE 'SYS%'
+ ), '') AS member_of,
+ '' AS members
+FROM (
+ SELECT DISTINCT GRANTEE FROM SYSCAT.DBAUTH
+ WHERE GRANTEETYPE = 'G' AND TRIM(GRANTEE) NOT IN ('', 'PUBLIC')
+ UNION
+ SELECT DISTINCT GRANTEE FROM SYSCAT.ROLEAUTH
+ WHERE GRANTEETYPE = 'G' AND TRIM(GRANTEE) NOT IN ('', 'PUBLIC')
+ UNION
+ SELECT DISTINCT GRANTEE FROM SYSCAT.TABAUTH
+ WHERE GRANTEETYPE = 'G' AND TRIM(GRANTEE) NOT IN ('', 'PUBLIC')
+) G
+UNION ALL
SELECT R.ROLENAME AS name,
'role' AS kind,
0 AS can_login,
@@ -882,7 +1259,7 @@ SELECT TRIM(GRANTEE) AS grantee, 'CONNECT' AS privilege, 'DATABASE' AS object_ty
NULL AS object_schema, NULL AS object_name,
CASE WHEN CONNECTAUTH = 'G' THEN 1 ELSE 0 END AS grantable, TRIM(GRANTOR) AS grantor
FROM SYSCAT.DBAUTH
-WHERE CONNECTAUTH IN ('Y', 'G') AND GRANTEETYPE = 'U'
+WHERE CONNECTAUTH IN ('Y', 'G') AND GRANTEETYPE IN ('U', 'G')
UNION ALL
SELECT TRIM(GRANTEE) AS grantee, 'SELECT' AS privilege, 'TABLE' AS object_type,
TRIM(TABSCHEMA) AS object_schema, TRIM(TABNAME) AS object_name,
@@ -906,21 +1283,62 @@ FROM SYSCAT.ROLEAUTH
WHERE ROLENAME NOT LIKE 'SYS%'
`.trim();
+/**
+ * Memberships live in system.role_grants, which the first version never read,
+ * so no ClickHouse account showed a role. ClickHouse has no correlated
+ * subqueries, hence the joins on pre-aggregated lists.
+ */
const CLICKHOUSE_PRINCIPALS = `
+SELECT p.name AS name, p.kind AS kind, p.can_login AS can_login,
+ m.member_of AS member_of, r.members AS members
+FROM (
+ SELECT name, 'user' AS kind, 1 AS can_login FROM system.users
+ UNION ALL
+ SELECT name, 'role', 0 FROM system.roles
+) p
+LEFT JOIN (
+ SELECT ifNull(user_name, role_name) AS who,
+ arrayStringConcat(groupArray(granted_role_name), ',') AS member_of
+ FROM system.role_grants
+ GROUP BY who
+) m ON m.who = p.name
+LEFT JOIN (
+ SELECT granted_role_name AS role,
+ arrayStringConcat(groupArray(ifNull(user_name, role_name)), ',') AS members
+ FROM system.role_grants
+ GROUP BY role
+) r ON r.role = p.name
+`.trim();
+
+const CLICKHOUSE_PRINCIPALS_NO_ROLE_GRANTS = `
SELECT name, 'user' AS kind, 1 AS can_login, '' AS member_of, '' AS members FROM system.users
UNION ALL
SELECT name, 'role', 0, '', '' FROM system.roles
`.trim();
+/**
+ * A grant with no database is `ON *.*`. The NULL used to fall through the
+ * `database != ''` test to 'SYSTEM', so every instance-wide grant read as a
+ * system authority. Role memberships come from system.role_grants.
+ */
const CLICKHOUSE_PRIVILEGES = `
-SELECT if(user_name != '', user_name, role_name) AS grantee,
- access_type AS privilege,
- if(table != '', 'TABLE', if(database != '', 'SCHEMA', 'SYSTEM')) AS object_type,
- nullIf(database, '') AS object_schema,
- nullIf(table, '') AS object_name,
+SELECT ifNull(user_name, role_name) AS grantee,
+ toString(access_type) AS privilege,
+ multiIf(ifNull(table, '') != '', 'TABLE', ifNull(database, '') != '', 'SCHEMA', 'GLOBAL') AS object_type,
+ nullIf(ifNull(database, ''), '') AS object_schema,
+ nullIf(ifNull(table, ''), '') AS object_name,
grant_option AS grantable,
NULL AS grantor
FROM system.grants
+UNION ALL
+SELECT ifNull(user_name, role_name),
+ granted_role_name,
+ 'ROLE',
+ NULL,
+ granted_role_name,
+ with_admin_option,
+ NULL
+FROM system.role_grants
`.trim();
const GENERIC_PRINCIPALS = `
diff --git a/packages/sql/src/modules/access/effective.ts b/packages/sql/src/modules/access/effective.ts
index f381c3866b91996c775735a510ba3b9580714d1d..556103214e03e1742a4551144c9f8a8a9231aa6e 100644
GIT binary patch
delta 48
ycmcar|D=9{tB`U`sR0nER2S&wCuOB3mjowgq~<0n*xM_ptJgv#H^&Lp=mG$>^AKYI
delta 38
tcmaD-f2)3jtB@pvN_Bx=eo|Iya!GJu_*4M6|^
diff --git a/packages/sql/src/modules/access/privilege-groups.test.ts b/packages/sql/src/modules/access/privilege-groups.test.ts
new file mode 100644
index 00000000..3e7ed2e9
--- /dev/null
+++ b/packages/sql/src/modules/access/privilege-groups.test.ts
@@ -0,0 +1,235 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { describe, expect, it } from 'vitest';
+import { buildGrantRevokeSql, type DbPrincipal, type DbPrivilege } from './db-access.js';
+import {
+ allPrivilegeTargets,
+ describeAllowAll,
+ findAllowAll,
+ groupPrivileges,
+ isAllPrivilegeSet,
+ privilegeTargetLabel,
+} from './privilege-groups.js';
+
+const priv = (
+ grantee: string,
+ privilege: string,
+ objectType: DbPrivilege['objectType'],
+ objectSchema: string | null = null,
+ objectName: string | null = null,
+ state: DbPrivilege['state'] = null
+): DbPrivilege => ({
+ grantee,
+ privilege,
+ objectType,
+ objectSchema,
+ objectName,
+ grantable: false,
+ grantor: null,
+ state,
+});
+
+const principal = (name: string, extra: Partial = {}): DbPrincipal => ({
+ name,
+ kind: 'user',
+ canLogin: true,
+ memberOf: [],
+ members: [],
+ ...extra,
+});
+
+/** What `GRANT ALL PRIVILEGES ON *.*` left in TiDB's USER_PRIVILEGES, verbatim. */
+const TIDB_GLOBAL_ALL =
+ 'ALTER,ALTER ROUTINE,CONFIG,CREATE,CREATE ROLE,CREATE ROUTINE,CREATE TABLESPACE,CREATE TEMPORARY TABLES,CREATE USER,CREATE VIEW,DELETE,DROP,DROP ROLE,EVENT,EXECUTE,FILE,INDEX,INSERT,LOCK TABLES,PROCESS,REFERENCES,RELOAD,REPLICATION CLIENT,REPLICATION SLAVE,SELECT,SHOW DATABASES,SHOW VIEW,SHUTDOWN,SUPER,TRIGGER,UPDATE'.split(
+ ','
+ );
+
+describe('isAllPrivilegeSet', () => {
+ it("recognises the MySQL family's expanded ALL PRIVILEGES ON *.*", () => {
+ expect(isAllPrivilegeSet('tidb', 'GLOBAL', TIDB_GLOBAL_ALL)).toBe(true);
+ expect(isAllPrivilegeSet('mysql', 'GLOBAL', TIDB_GLOBAL_ALL)).toBe(true);
+ });
+
+ it('does not call a partial set ALL', () => {
+ expect(isAllPrivilegeSet('mysql', 'GLOBAL', ['SELECT', 'INSERT'])).toBe(false);
+ expect(isAllPrivilegeSet('mysql', 'GLOBAL', TIDB_GLOBAL_ALL.filter((p) => p !== 'SHUTDOWN'))).toBe(
+ false
+ );
+ });
+
+ it("recognises Postgres's seven table privileges, and a superset", () => {
+ const seven = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'];
+ expect(isAllPrivilegeSet('postgres', 'TABLE', seven)).toBe(true);
+ expect(isAllPrivilegeSet('postgres', 'TABLE', [...seven, 'MAINTAIN'])).toBe(true);
+ expect(isAllPrivilegeSet('postgres', 'TABLE', seven.slice(1))).toBe(false);
+ });
+
+ it('takes ALL, ALL PRIVILEGES and CONTROL at their word', () => {
+ expect(isAllPrivilegeSet('clickhouse', 'GLOBAL', ['ALL'])).toBe(true);
+ expect(isAllPrivilegeSet('sqlserver', 'DATABASE', ['CONTROL'])).toBe(true);
+ expect(isAllPrivilegeSet('oracle', 'TABLE', ['all privileges'])).toBe(true);
+ });
+
+ it('knows no set for an engine it has none for', () => {
+ expect(isAllPrivilegeSet('db2', 'TABLE', ['SELECT', 'INSERT'])).toBe(false);
+ expect(isAllPrivilegeSet('mysql', 'GLOBAL', [])).toBe(false);
+ });
+});
+
+describe('groupPrivileges', () => {
+ it('groups by object, keeps DENY apart, and leaves role memberships out', () => {
+ const groups = groupPrivileges(
+ [
+ priv('a', 'SELECT', 'TABLE', 'dbo', 't'),
+ priv('a', 'INSERT', 'TABLE', 'dbo', 't'),
+ priv('a', 'DELETE', 'TABLE', 'dbo', 't', 'deny'),
+ priv('a', 'reader', 'ROLE', null, 'reader'),
+ priv('a', 'SELECT', 'TABLE', 'dbo', 'u'),
+ ],
+ 'sqlserver'
+ );
+ expect(groups.map((g) => [g.objectName, g.state, g.privileges.length])).toEqual([
+ ['t', null, 2],
+ ['t', 'deny', 1],
+ ['u', null, 1],
+ ]);
+ });
+
+ it('marks a complete set, and never a DENY', () => {
+ const groups = groupPrivileges(
+ [
+ ...TIDB_GLOBAL_ALL.map((p) => priv('app@%', p, 'GLOBAL')),
+ priv('x', 'CONTROL', 'DATABASE', null, null, 'deny'),
+ ],
+ 'tidb'
+ );
+ expect(groups.map((g) => g.all)).toEqual([true, false]);
+ });
+});
+
+describe('privilegeTargetLabel', () => {
+ it('says *.* in words', () => {
+ expect(privilegeTargetLabel({ objectType: 'GLOBAL', objectSchema: null, objectName: null })).toBe(
+ 'every database (*.*)'
+ );
+ expect(privilegeTargetLabel({ objectType: 'SCHEMA', objectSchema: 'demo_a', objectName: null })).toBe(
+ 'demo_a.* (schema)'
+ );
+ expect(privilegeTargetLabel({ objectType: 'TABLE', objectSchema: 's', objectName: 't' })).toBe('s.t');
+ });
+});
+
+describe('findAllowAll', () => {
+ const globalAll = (who: string) => TIDB_GLOBAL_ALL.map((p) => priv(who, p, 'GLOBAL'));
+
+ it('finds a superuser', () => {
+ const allow = findAllowAll({
+ principal: 'root',
+ principals: [principal('root', { superuser: true })],
+ privileges: [],
+ dialect: 'postgres',
+ });
+ expect(allow).toEqual({ kind: 'superuser', holder: 'root', via: [] });
+ expect(describeAllowAll(allow!)).toMatch(/bypasses every permission check/);
+ });
+
+ it('finds every privilege ON *.*', () => {
+ const allow = findAllowAll({
+ principal: 'app@%',
+ principals: [principal('app@%')],
+ privileges: globalAll('app@%'),
+ dialect: 'mysql',
+ });
+ expect(allow?.kind).toBe('all-on-server');
+ });
+
+ it('follows role membership, nearest holder first', () => {
+ const allow = findAllowAll({
+ principal: 'app@%',
+ principals: [
+ principal('app@%', { memberOf: ['ops@%'] }),
+ principal('ops@%', { kind: 'role', memberOf: ['admin@%'] }),
+ principal('admin@%', { kind: 'role' }),
+ ],
+ privileges: globalAll('admin@%'),
+ dialect: 'mysql',
+ });
+ expect(allow).toEqual({ kind: 'all-on-server', holder: 'admin@%', via: ['ops@%', 'admin@%'] });
+ expect(describeAllowAll(allow!)).toMatch(/Inherited through ops@% → admin@%/);
+ });
+
+ it("finds SQL Server's db_owner through its implied CONTROL", () => {
+ const allow = findAllowAll({
+ principal: 'app',
+ principals: [principal('app', { memberOf: ['db_owner'] }), principal('db_owner', { kind: 'role' })],
+ privileges: [{ ...priv('db_owner', 'CONTROL', 'DATABASE'), state: 'grant', source: 'implied' }],
+ dialect: 'sqlserver',
+ });
+ expect(allow).toEqual({ kind: 'all-on-database', holder: 'db_owner', via: ['db_owner'] });
+ });
+
+ it("does not call Postgres ALL ON DATABASE allow-all: it reads no table", () => {
+ const allow = findAllowAll({
+ principal: 'app',
+ principals: [principal('app')],
+ privileges: ['CONNECT', 'CREATE', 'TEMPORARY'].map((p) => priv('app', p, 'DATABASE', null, 'foxdb')),
+ dialect: 'postgres',
+ });
+ expect(allow).toBeNull();
+ });
+
+ it('is null for an ordinary account', () => {
+ expect(
+ findAllowAll({
+ principal: 'app@%',
+ principals: [principal('app@%')],
+ privileges: [priv('app@%', 'SELECT', 'GLOBAL')],
+ dialect: 'mysql',
+ })
+ ).toBeNull();
+ });
+});
+
+describe('allPrivilegeTargets', () => {
+ const grantAll = (dialect: string, id: string, grantee: string) => {
+ const t = allPrivilegeTargets(dialect, { database: 'app', schema: 'sales' }).find((x) => x.id === id);
+ if (!t) throw new Error(`${dialect} offers no ${id}`);
+ const built = buildGrantRevokeSql({
+ dialect,
+ action: 'grant',
+ privilege: t.privilege,
+ objectType: t.objectType,
+ objectSchema: t.objectSchema,
+ objectName: t.objectName,
+ grantee,
+ });
+ if ('error' in built) throw new Error(built.error);
+ return built.sql;
+ };
+
+ it('spells "everything" the way each engine does', () => {
+ expect(grantAll('mysql', 'server', 'app@%')).toBe("GRANT ALL PRIVILEGES ON *.* TO 'app'@'%';");
+ expect(grantAll('tidb', 'database', 'app@%')).toBe("GRANT ALL PRIVILEGES ON `sales`.* TO 'app'@'%';");
+ expect(grantAll('clickhouse', 'server', 'app')).toBe('GRANT ALL ON *.* TO `app`;');
+ expect(grantAll('clickhouse', 'database', 'app')).toBe('GRANT ALL ON `sales`.* TO `app`;');
+ expect(grantAll('postgres', 'database', 'app')).toBe('GRANT ALL ON DATABASE "app" TO "app";');
+ expect(grantAll('postgres', 'schema', 'app')).toBe('GRANT ALL ON SCHEMA "sales" TO "app";');
+ expect(grantAll('sqlserver', 'database', 'app')).toBe('GRANT CONTROL ON DATABASE::[app] TO [app];');
+ expect(grantAll('oracle', 'system', 'APP')).toBe('GRANT ALL PRIVILEGES TO "APP";');
+ expect(grantAll('db2', 'database', 'APP')).toBe('GRANT DBADM ON DATABASE TO USER "APP";');
+ });
+
+ it('offers Postgres no server-wide grant, and says why', () => {
+ const targets = allPrivilegeTargets('postgres', { database: 'app', schema: 'sales' });
+ expect(targets.map((t) => t.id)).toEqual(['database', 'schema']);
+ expect(targets[0]!.note).toMatch(/reads no table/);
+ expect(targets[0]!.note).toMatch(/SUPERUSER/);
+ });
+
+ it('offers nothing on engines without GRANT', () => {
+ expect(allPrivilegeTargets('sqlite', { database: 'x' })).toEqual([]);
+ });
+});
diff --git a/packages/sql/src/modules/access/privilege-groups.ts b/packages/sql/src/modules/access/privilege-groups.ts
new file mode 100644
index 00000000..18d4f581
--- /dev/null
+++ b/packages/sql/src/modules/access/privilege-groups.ts
@@ -0,0 +1,345 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Reading a privilege list the way a DBA does: by object, and with "all of
+ * them" said once.
+ *
+ * Engines store `GRANT ALL` expanded — MySQL keeps `ALL PRIVILEGES ON *.*` as
+ * some thirty rows, Postgres keeps `ALL ON TABLE` as seven — so a list of rows
+ * buries the one fact that matters, that this account may do anything. These
+ * helpers group rows per object, say when a group is the engine's whole set,
+ * and find accounts that are allowed everything, directly or through a role.
+ *
+ * Pure: it takes rows the probes already fetched.
+ */
+import { accessFamily } from './intent.js';
+import type { DbPrincipal, DbPrivilege, DbPrivilegeObjectType } from './db-access.js';
+import { resolveRoleChain } from './effective.js';
+
+/**
+ * The privileges `GRANT ALL` expands to, per engine family and object level.
+ *
+ * A group holding every name here is reported as ALL. The MySQL global set is
+ * the part MySQL 8, MariaDB 11 and TiDB share: each adds its own dynamic
+ * privileges on top (MariaDB's BINLOG ADMIN, TiDB's CONFIG), so demanding the
+ * full list of any one would miss the other two.
+ */
+const ALL_SETS: Record>> = {
+ mysql: {
+ GLOBAL: [
+ 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES',
+ 'CREATE USER', 'CREATE VIEW', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'FILE', 'INDEX',
+ 'INSERT', 'LOCK TABLES', 'PROCESS', 'REFERENCES', 'RELOAD', 'REPLICATION SLAVE',
+ 'SELECT', 'SHOW VIEW', 'SHUTDOWN', 'TRIGGER', 'UPDATE',
+ ],
+ SCHEMA: [
+ 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES',
+ 'CREATE VIEW', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES',
+ 'REFERENCES', 'SELECT', 'SHOW VIEW', 'TRIGGER', 'UPDATE',
+ ],
+ TABLE: [
+ 'ALTER', 'CREATE', 'CREATE VIEW', 'DELETE', 'DROP', 'INDEX', 'INSERT', 'REFERENCES',
+ 'SELECT', 'SHOW VIEW', 'TRIGGER', 'UPDATE',
+ ],
+ },
+ postgres: {
+ // Postgres 17 adds MAINTAIN; a superset still counts.
+ TABLE: ['DELETE', 'INSERT', 'REFERENCES', 'SELECT', 'TRIGGER', 'TRUNCATE', 'UPDATE'],
+ SCHEMA: ['CREATE', 'USAGE'],
+ DATABASE: ['CONNECT', 'CREATE', 'TEMPORARY'],
+ },
+};
+
+/** Names that mean "everything at this level" on their own. */
+const ALL_NAMES = new Set(['ALL', 'ALL PRIVILEGES', 'CONTROL']);
+
+function setFamily(dialect: string): string {
+ const fam = accessFamily(dialect);
+ return fam === 'mariadb' ? 'mysql' : fam;
+}
+
+/** Whether these privilege names are the engine's whole set for this kind of object. */
+export function isAllPrivilegeSet(
+ dialect: string,
+ objectType: DbPrivilegeObjectType,
+ names: readonly string[]
+): boolean {
+ const held = new Set(names.map((n) => n.trim().toUpperCase()));
+ for (const n of held) if (ALL_NAMES.has(n)) return true;
+ const set = ALL_SETS[setFamily(dialect)]?.[objectType];
+ if (!set || held.size === 0) return false;
+ return set.every((p) => held.has(p));
+}
+
+export interface PrivilegeGroup {
+ key: string;
+ objectType: DbPrivilegeObjectType;
+ objectSchema: string | null;
+ objectName: string | null;
+ state: DbPrivilege['state'];
+ privileges: DbPrivilege[];
+ /** The group is the engine's whole set for this object: show it as ALL. */
+ all: boolean;
+}
+
+/**
+ * One group per object (and per GRANT/DENY, which must never be merged), in
+ * first-seen order. Role memberships are not object privileges and are left
+ * out.
+ */
+export function groupPrivileges(
+ privileges: readonly DbPrivilege[],
+ dialect: string
+): PrivilegeGroup[] {
+ const groups = new Map();
+ for (const p of privileges) {
+ if (p.objectType === 'ROLE') continue;
+ const key = [p.objectType, p.objectSchema ?? '', p.objectName ?? '', p.state ?? ''].join('\u0000');
+ let group = groups.get(key);
+ if (!group) {
+ group = {
+ key,
+ objectType: p.objectType,
+ objectSchema: p.objectSchema,
+ objectName: p.objectName,
+ state: p.state,
+ privileges: [],
+ all: false,
+ };
+ groups.set(key, group);
+ }
+ group.privileges.push(p);
+ }
+ for (const group of groups.values()) {
+ group.all =
+ group.state !== 'deny' &&
+ isAllPrivilegeSet(
+ dialect,
+ group.objectType,
+ group.privileges.map((p) => p.privilege)
+ );
+ }
+ return [...groups.values()];
+}
+
+/** Where a privilege row points, in words: `*.*` rather than "GLOBAL". */
+export function privilegeTargetLabel(p: {
+ objectType: DbPrivilegeObjectType;
+ objectSchema: string | null;
+ objectName: string | null;
+}): string {
+ if (p.objectType === 'GLOBAL') return 'every database (*.*)';
+ const named = [p.objectSchema, p.objectName].filter(Boolean).join('.');
+ if (p.objectType === 'SCHEMA' && named) return `${named}.* (schema)`;
+ if (p.objectType === 'DATABASE') return named ? `database ${named}` : 'this database';
+ return named || p.objectType;
+}
+
+export type AllowAllKind = 'superuser' | 'all-on-server' | 'all-on-database';
+
+export interface AllowAll {
+ kind: AllowAllKind;
+ /** The principal that holds it — the account itself, or a role it is in. */
+ holder: string;
+ /** Role hops from the account to the holder; empty when held directly. */
+ via: string[];
+}
+
+function key(name: string): string {
+ return (name || '').trim().toLowerCase();
+}
+
+function directAllowAll(
+ holder: DbPrincipal | undefined,
+ holderName: string,
+ privileges: readonly DbPrivilege[],
+ dialect: string
+): AllowAllKind | null {
+ if (holder?.superuser === true) return 'superuser';
+ const own = privileges.filter((p) => key(p.grantee) === key(holderName));
+ for (const group of groupPrivileges(own, dialect)) {
+ if (!group.all) continue;
+ if (group.objectType === 'GLOBAL') return 'all-on-server';
+ // SQL Server CONTROL on the database (db_owner) is everything in it.
+ if (group.objectType === 'DATABASE' && accessFamily(dialect) === 'sqlserver') {
+ return 'all-on-database';
+ }
+ }
+ return null;
+}
+
+/**
+ * Whether this account may do anything, and through whom.
+ *
+ * "Anything" is deliberately narrow: a superuser, every privilege on the whole
+ * server (`ON *.*`), or control of the whole database. Postgres `ALL ON
+ * DATABASE` is not it — that is CONNECT, CREATE and TEMPORARY, and reads no
+ * table. Nearest holder wins, so a direct grant is reported before a role's.
+ */
+export function findAllowAll(opts: {
+ principal: string;
+ principals: readonly DbPrincipal[];
+ privileges: readonly DbPrivilege[];
+ dialect: string;
+}): AllowAll | null {
+ const byName = new Map(opts.principals.map((p) => [key(p.name), p]));
+ const direct = directAllowAll(byName.get(key(opts.principal)), opts.principal, opts.privileges, opts.dialect);
+ if (direct) return { kind: direct, holder: opts.principal, via: [] };
+ const chains = [...resolveRoleChain(opts.principal, opts.principals).values()].sort(
+ (a, b) => a.length - b.length
+ );
+ for (const chain of chains) {
+ const role = chain[chain.length - 1]!;
+ const kind = directAllowAll(byName.get(key(role)), role, opts.privileges, opts.dialect);
+ if (kind) return { kind, holder: byName.get(key(role))?.name ?? role, via: chain };
+ }
+ return null;
+}
+
+/** One line for a badge's tooltip or a banner. */
+export function describeAllowAll(allow: AllowAll): string {
+ const what =
+ allow.kind === 'superuser'
+ ? 'Superuser: bypasses every permission check, so the grants listed do not limit it.'
+ : allow.kind === 'all-on-server'
+ ? 'Holds every privilege on the whole server (*.*).'
+ : 'Controls the whole database.';
+ if (allow.via.length === 0) return what;
+ return `${what} Inherited through ${allow.via.join(' → ')}.`;
+}
+
+/** One way to grant "everything" on this engine, for the grant form to offer. */
+export interface AllPrivilegeTarget {
+ id: string;
+ /** What the reader picks, in their terms. */
+ label: string;
+ /** What it really confers, and what it does not. */
+ note: string;
+ objectType: DbPrivilegeObjectType;
+ objectSchema: string | null;
+ objectName: string | null;
+ /** The engine's own name for "everything" at this level. */
+ privilege: string;
+}
+
+/**
+ * The allow-all grants this engine has, widest first.
+ *
+ * Each engine spells "everything" differently and at different levels, and
+ * some levels do not exist: Postgres has no server-wide grant — that is
+ * superuser, an account attribute set with ALTER ROLE, not a GRANT — and
+ * `ALL ON DATABASE` there reads no table. The notes say so, because the word
+ * ALL promises more than several of these deliver.
+ */
+export function allPrivilegeTargets(
+ dialect: string,
+ ctx: { database?: string | null; schema?: string | null }
+): AllPrivilegeTarget[] {
+ const fam = accessFamily(dialect);
+ const db = ctx.database?.trim() || null;
+ const schema = ctx.schema?.trim() || null;
+ const out: AllPrivilegeTarget[] = [];
+ const target = (t: AllPrivilegeTarget) => out.push(t);
+
+ if (fam === 'mysql' || fam === 'mariadb' || fam === 'clickhouse') {
+ target({
+ id: 'server',
+ label: 'Whole server (*.*)',
+ note: 'Every privilege on every database, including creating users and shutting the server down.',
+ objectType: 'GLOBAL',
+ objectSchema: null,
+ objectName: null,
+ privilege: 'ALL',
+ });
+ const name = schema || db;
+ if (name) {
+ target({
+ id: 'database',
+ label: `Database ${name} (${name}.*)`,
+ note: `Every privilege on ${name} and every table in it, including tables created later.`,
+ objectType: 'DATABASE',
+ objectSchema: null,
+ objectName: name,
+ privilege: 'ALL',
+ });
+ }
+ return out;
+ }
+ if (fam === 'postgres') {
+ if (db) {
+ target({
+ id: 'database',
+ label: `Database ${db}`,
+ note: 'CONNECT, CREATE and TEMPORARY on the database. It reads no table: that is granted per schema or table. Server-wide access is superuser (ALTER ROLE … SUPERUSER), not a grant.',
+ objectType: 'DATABASE',
+ objectSchema: null,
+ objectName: db,
+ privilege: 'ALL',
+ });
+ }
+ if (schema) {
+ target({
+ id: 'schema',
+ label: `Schema ${schema}`,
+ note: 'USAGE and CREATE on the schema. The tables in it are granted separately.',
+ objectType: 'SCHEMA',
+ objectSchema: null,
+ objectName: schema,
+ privilege: 'ALL',
+ });
+ }
+ return out;
+ }
+ if (fam === 'sqlserver') {
+ if (db) {
+ target({
+ id: 'database',
+ label: `Database ${db} (CONTROL)`,
+ note: 'CONTROL on the database: every permission in it, as db_owner has.',
+ objectType: 'DATABASE',
+ objectSchema: null,
+ objectName: db,
+ privilege: 'CONTROL',
+ });
+ }
+ if (schema) {
+ target({
+ id: 'schema',
+ label: `Schema ${schema} (CONTROL)`,
+ note: `CONTROL on the schema: every permission on ${schema} and everything in it.`,
+ objectType: 'SCHEMA',
+ objectSchema: null,
+ objectName: schema,
+ privilege: 'CONTROL',
+ });
+ }
+ return out;
+ }
+ if (fam === 'oracle') {
+ target({
+ id: 'system',
+ label: 'Every system privilege (ALL PRIVILEGES)',
+ note: 'Every system privilege, including SELECT ANY TABLE and DROP ANY TABLE on every schema.',
+ objectType: 'SYSTEM',
+ objectSchema: null,
+ objectName: null,
+ privilege: 'ALL PRIVILEGES',
+ });
+ return out;
+ }
+ if (fam === 'db2') {
+ target({
+ id: 'database',
+ label: 'Database administrator (DBADM)',
+ note: 'DBADM on this database: create and drop objects, and with DATAACCESS read and write every table.',
+ objectType: 'DATABASE',
+ objectSchema: null,
+ objectName: null,
+ privilege: 'DBADM',
+ });
+ return out;
+ }
+ return out;
+}
diff --git a/packages/sql/src/modules/access/user-sql.test.ts b/packages/sql/src/modules/access/user-sql.test.ts
index e00a6c68..5bb0e8b1 100644
--- a/packages/sql/src/modules/access/user-sql.test.ts
+++ b/packages/sql/src/modules/access/user-sql.test.ts
@@ -574,3 +574,49 @@ describe('buildUserSql — quoting', () => {
expect('error' in buildUserSql(req({ name: ' ' }), 'postgres')).toBe(true);
});
});
+
+describe('create with role membership', () => {
+ const sqlOf = (dialect: string, extra: Partial = {}) => {
+ const out = buildUserSql(
+ { action: 'create', principalType: 'user', name: 'app', host: '%', roles: ['reader'], ...extra },
+ dialect
+ );
+ if ('error' in out) throw new Error(out.error);
+ return out.statements.map((s) => s.sql);
+ };
+
+ it('grants each role after the CREATE on Postgres', () => {
+ const sql = sqlOf('postgres', { roles: ['reader', 'writer'] });
+ expect(sql.slice(-2)).toEqual(['GRANT "reader" TO "app";', 'GRANT "writer" TO "app";']);
+ expect(sql[0]).toMatch(/^CREATE (ROLE|USER)/);
+ });
+
+ it('turns the roles on at login on MySQL and TiDB', () => {
+ for (const dialect of ['mysql', 'tidb']) {
+ expect(sqlOf(dialect, { roles: ['reader@%'] }).slice(-2), dialect).toEqual([
+ "GRANT 'reader'@'%' TO 'app'@'%';",
+ "SET DEFAULT ROLE ALL TO 'app'@'%';",
+ ]);
+ }
+ });
+
+ it('names a MariaDB role bare and sets exactly one default', () => {
+ expect(sqlOf('mariadb', { roles: ['reader', 'writer'] }).slice(-3)).toEqual([
+ "GRANT 'reader' TO 'app'@'%';",
+ "GRANT 'writer' TO 'app'@'%';",
+ "SET DEFAULT ROLE 'reader' FOR 'app'@'%';",
+ ]);
+ });
+
+ it('adds nothing when no role is chosen, or when the account is being altered', () => {
+ const plain = buildUserSql({ action: 'create', principalType: 'user', name: 'app' }, 'postgres');
+ const alter = buildUserSql(
+ { action: 'alter', principalType: 'user', name: 'app', alteration: 'password', roles: ['reader'] },
+ 'postgres'
+ );
+ for (const out of [plain, alter]) {
+ if ('error' in out) throw new Error(out.error);
+ expect(out.statements.some((s) => /GRANT/.test(s.sql))).toBe(false);
+ }
+ });
+});
diff --git a/packages/sql/src/modules/access/user-sql.ts b/packages/sql/src/modules/access/user-sql.ts
index 824614ff..b55dda7c 100644
--- a/packages/sql/src/modules/access/user-sql.ts
+++ b/packages/sql/src/modules/access/user-sql.ts
@@ -55,7 +55,10 @@ export {
} from '../../providers/db2/db2.user-sql.js';
import type { GeneratedUserSql, UserManagementSupport, UserRequest } from './user-sql.types.js';
+import type { GeneratedStatement } from './access-sql.types.js';
import { resolveUserSql } from './user-sql.registry.js';
+import { buildGrantRevokeSql, formatDbGrantee } from './db-access.js';
+import { accessFamily } from './intent.js';
export function userManagementSupport(dialect: string): UserManagementSupport {
return { ...resolveUserSql(dialect).support };
@@ -86,5 +89,67 @@ export function buildUserSql(
return { error: support.reason ?? 'This engine cannot create users in SQL.' };
}
- return impl.build({ ...request, name }, dialect);
+ const built = impl.build({ ...request, name }, dialect);
+ if ('error' in built) return built;
+ const roles = (request.roles ?? []).map((r) => r.trim()).filter(Boolean);
+ if (request.action !== 'create' || roles.length === 0) return built;
+ const memberships = roleMembershipStatements(request, name, roles, dialect);
+ if ('error' in memberships) return memberships;
+ return { ...built, statements: [...built.statements, ...memberships] };
+}
+
+/**
+ * GRANT each chosen role to the account just created.
+ *
+ * On the MySQL family a granted role is inactive until the session turns it on,
+ * so without a default role the new account logs in holding none of what it
+ * was just given. MySQL and TiDB take `ALL`; MariaDB takes exactly one.
+ */
+function roleMembershipStatements(
+ request: UserRequest,
+ name: string,
+ roles: string[],
+ dialect: string
+): GeneratedStatement[] | { error: string } {
+ const fam = accessFamily(dialect);
+ const mysqlFamily = fam === 'mysql' || fam === 'mariadb';
+ const isUser = request.principalType === 'user';
+ const grantee = mysqlFamily && isUser ? `${name}@${request.host?.trim() || '%'}` : name;
+ const out: GeneratedStatement[] = [];
+ for (const role of roles) {
+ const built = buildGrantRevokeSql({
+ dialect,
+ action: 'grant',
+ privilege: role,
+ objectType: 'ROLE',
+ objectName: role,
+ grantee,
+ granteeKind: request.principalType,
+ });
+ if ('error' in built) return built;
+ out.push({
+ sql: built.sql,
+ explanation: `Adds ${name} to ${role}, so it holds everything ${role} holds.`,
+ risk: 'elevated',
+ });
+ }
+ if (mysqlFamily && isUser) {
+ const account = formatDbGrantee(dialect, grantee, 'user');
+ out.push(
+ fam === 'mariadb'
+ ? {
+ sql: `SET DEFAULT ROLE ${formatDbGrantee(dialect, roles[0]!, 'role')} FOR ${account};`,
+ explanation: `Turns ${roles[0]} on at login. MariaDB keeps one default role${
+ roles.length > 1 ? '; the others are switched on with SET ROLE' : ''
+ }.`,
+ risk: 'low',
+ }
+ : {
+ sql: `SET DEFAULT ROLE ALL TO ${account};`,
+ explanation: 'Turns the granted roles on at login. MySQL leaves a granted role inactive otherwise.',
+ risk: 'low',
+ }
+ );
+ }
+ return out;
}
diff --git a/packages/sql/src/modules/access/user-sql.types.ts b/packages/sql/src/modules/access/user-sql.types.ts
index 052637dd..7d493323 100644
--- a/packages/sql/src/modules/access/user-sql.types.ts
+++ b/packages/sql/src/modules/access/user-sql.types.ts
@@ -48,6 +48,12 @@ export interface UserRequest {
host?: string;
/** Drop objects the account owns as well. Oracle needs this to drop at all. */
cascade?: boolean;
+ /**
+ * For `create`: existing roles or groups to put the new account in. Each
+ * becomes a GRANT after the CREATE, so an account can be made and placed in
+ * one review instead of two trips through two screens.
+ */
+ roles?: string[];
}
export interface GeneratedUserSql {
From a0e4c351592136e4522ca9aca83c2fa21ada1ca8 Mon Sep 17 00:00:00 2001
From: huyplb
Date: Fri, 25 Sep 2026 22:58:25 -0600
Subject: [PATCH 2/2] test(access): wait for the principal row, not the catalog
call
The modal tests clicked db-access-principal-alice as soon as fetchDbAccess
had been called. The mock resolves a tick later, so on a slow runner the
list was still empty and CI failed 'lists the membership under Role
memberships' with no element to click. Wait for the row itself.
Co-Authored-By: Claude Opus 5.5
---
.../components/DatabaseAccessModal.test.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
index abaa5274..149937c9 100644
--- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
+++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx
@@ -118,10 +118,13 @@ describe('DatabaseAccessModal', () => {
fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
await waitFor(() => expect(fetchDbAccess).toHaveBeenCalled());
- expect(screen.getByTestId('db-access-group-role').textContent).toMatch(/analysts/);
+ // The group renders empty before the catalog arrives; wait for its rows.
+ await waitFor(() =>
+ expect(screen.getByTestId('db-access-group-role').textContent).toMatch(/analysts/)
+ );
expect(screen.getByTestId('db-access-group-user').textContent).toMatch(/alice/);
- fireEvent.click(screen.getByTestId('db-access-principal-alice'));
+ fireEvent.click(await screen.findByTestId('db-access-principal-alice'));
expect(screen.getByTestId('db-access-privileges').textContent).toMatch(/SELECT/);
expect(screen.getByTestId('db-access-privileges').textContent).toMatch(/public\.orders/);
@@ -259,7 +262,7 @@ describe('DatabaseAccessModal — role membership is not an object privilege', (
render( undefined} />);
fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
await waitFor(() => expect(fetchDbAccess).toHaveBeenCalled());
- fireEvent.click(screen.getByTestId('db-access-principal-alice'));
+ fireEvent.click(await screen.findByTestId('db-access-principal-alice'));
const privileges = screen.getByTestId('db-access-privileges').textContent ?? '';
const memberships = screen.getByTestId('db-access-memberships').textContent ?? '';
@@ -279,7 +282,7 @@ describe('DatabaseAccessModal — role membership is not an object privilege', (
render( undefined} />);
fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
await waitFor(() => expect(fetchDbAccess).toHaveBeenCalled());
- fireEvent.click(screen.getByTestId('db-access-principal-alice'));
+ fireEvent.click(await screen.findByTestId('db-access-principal-alice'));
const kind = screen.getByTestId('db-access-grant-kind') as HTMLSelectElement;
// Postgres has database-level ALL, so allow-all is offered too.
@@ -297,7 +300,7 @@ describe('DatabaseAccessModal — dialect-aware general CREATE', () => {
render( undefined} />);
fireEvent.change(screen.getByTestId('db-access-connection'), { target: { value: 'c1' } });
await waitFor(() => expect(fetchDbAccess).toHaveBeenCalled());
- fireEvent.click(screen.getByTestId('db-access-principal-alice'));
+ fireEvent.click(await screen.findByTestId('db-access-principal-alice'));
fireEvent.click(screen.getByTestId('db-access-grant-general'));
await waitFor(() => expect(screen.getByTestId('db-access-general-editor')).toBeTruthy());