Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 129 additions & 1 deletion apps/e2e/src/tests/database-access-dialects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest';
import type { Page } from 'playwright';
import { buildDriver, quitDriver } from '../helpers/driver.js';
import { getSourceConfig, hasConfig } from '../helpers/db-config.js';
import { deleteSavedConnections, engineAcceptsSyntax } from '../helpers/sql-exec.js';
import { deleteSavedConnections, engineAcceptsSyntax, tryCleanup } from '../helpers/sql-exec.js';
import { clickRateLimited } from '../helpers/rate-limited.js';
import { saveScreenshot } from '../helpers/screenshot.js';
import { AppPage } from '../pages/AppPage.js';
Expand Down Expand Up @@ -92,6 +92,87 @@ const only = (process.env.E2E_DIALECTS ?? '')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);

/**
* A role, an account in it, and an allow-all grant, the way each engine spells
* them — so the test reads back what the engine really holds.
*
* `userRowName` is how the Users list names the account (MySQL family: with
* its host); `allowAll` is the tag expected on it, or null for none.
*/
function rolesFixture(
dialect: string,
runId: string,
password: string
): {
role: string;
user: string;
userRowName: string;
allowAll: 'allow-all' | 'superuser' | null;
setup: string[];
teardown: string[];
} | null {
const role = `fox_ro_${runId}`;
const user = `fox_ua_${runId}`;
if (dialect === 'mysql' || dialect === 'tidb') {
return {
role: `${role}@%`,
user: `${user}@%`,
userRowName: `${user}@%`,
allowAll: 'allow-all',
setup: [
`CREATE ROLE '${role}'@'%'`,
`CREATE USER '${user}'@'%' IDENTIFIED BY '${password}'`,
`GRANT '${role}'@'%' TO '${user}'@'%'`,
`GRANT ALL PRIVILEGES ON *.* TO '${user}'@'%'`,
],
teardown: [`DROP USER '${user}'@'%'`, `DROP ROLE '${role}'@'%'`],
};
}
if (dialect === 'mariadb') {
return {
role,
user: `${user}@%`,
userRowName: `${user}@%`,
allowAll: 'allow-all',
setup: [
`CREATE ROLE ${role}`,
`CREATE USER '${user}'@'%' IDENTIFIED BY '${password}'`,
`GRANT ${role} TO '${user}'@'%'`,
`GRANT ALL PRIVILEGES ON *.* TO '${user}'@'%'`,
],
teardown: [`DROP USER '${user}'@'%'`, `DROP ROLE ${role}`],
};
}
if (dialect === 'postgres') {
return {
role,
user,
userRowName: user,
allowAll: 'superuser',
setup: [`CREATE ROLE ${role}`, `CREATE ROLE ${user} LOGIN SUPERUSER IN ROLE ${role}`],
teardown: [`DROP ROLE ${user}`, `DROP ROLE ${role}`],
};
}
if (dialect === 'clickhouse') {
// The e2e `default` user may not grant ALL; SELECT ON *.* is enough to
// check that an instance-wide grant is read, and it is not allow-all.
return {
role,
user,
userRowName: user,
allowAll: null,
setup: [
`CREATE ROLE ${role}`,
`CREATE USER ${user} IDENTIFIED BY '${password}'`,
`GRANT ${role} TO ${user}`,
`GRANT SELECT ON *.* TO ${user}`,
],
teardown: [`DROP USER ${user}`, `DROP ROLE ${role}`],
};
}
return null;
}

const configured = ALL_DIALECTS.filter(
(d) => hasConfig(d) && (only.length === 0 || only.includes(d))
);
Expand Down Expand Up @@ -679,6 +760,53 @@ describe.skipIf(configured.length === 0)('Database Access · User Management', (
await format.selectOption('raw');
await saveScreenshot(driver, `dbaccess-command-${dialect}`);
}, 120_000);

it('lists roles as roles, with their members, and tags an account allowed everything', async () => {
const fixture = rolesFixture(dialect, runId, password);
if (!fixture) return;

const setup = await engineAcceptsSyntax(dialect, fixture.setup);
expect(setup.rejected ?? '', setup.rejected ?? '').toBe('');
if (setup.skipped) {
// This login may not manage roles (the TiDB e2e user, for one). That
// says nothing about how Fox reads them, so there is nothing to test.
console.warn(`[db-access] ${dialect} could not set up roles: ${setup.skipped}`);
await tryCleanup(dialect, fixture.teardown);
return;
}
try {
await driver.locator('[data-testid="access-tab-users"]').click();
await selectConnection(dialect);
await refreshCatalog();
await driver.waitForSelector(rowFor(fixture.user), { timeout: 60_000 });
await catalogIdle();

// The role was listed as a user on the MySQL family, which emptied
// "Roles & groups" on every one of them.
expect(
await driver.locator(rowFor(fixture.role)).first().getAttribute('data-kind'),
`${dialect} lists ${fixture.role} as a role`
).toBe('role');
const userRow = driver.locator(rowFor(fixture.user)).first();
expect(await userRow.getAttribute('data-kind')).toBe('user');
expect(await userRow.innerText(), `${dialect} shows ${fixture.user}'s role`).toContain(
fixture.role
);

// `GRANT ALL ON *.*` and a superuser both used to read as holding
// nothing at all.
const tag = driver.locator(`[data-testid="user-allow-all-${fixture.userRowName}"]`);
if (fixture.allowAll) {
expect(await tag.count(), `${dialect} tags ${fixture.user} as allowed everything`).toBe(1);
expect((await tag.innerText()).toLowerCase()).toBe(fixture.allowAll);
} else {
expect(await tag.count()).toBe(0);
}
await saveScreenshot(driver, `dbaccess-roles-${dialect}`);
} finally {
await tryCleanup(dialect, fixture.teardown);
}
}, 180_000);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,32 @@ describe('AccessPermissionPanel — one session', () => {
expect(runAccessSql).not.toHaveBeenCalled();
});
});

describe('AccessPermissionPanel — allow-all', () => {
async function accountOf(name: string) {
render(<AccessPermissionPanel />);
fireEvent.change(screen.getByTestId('access-permission-connection'), { target: { value: 'c1' } });
await waitFor(() => expect(screen.getByTestId(`access-permission-row-${name}`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`access-permission-row-${name}`));
fireEvent.click(screen.getByTestId('access-permission-stage-account'));
}

it('says an account inherits superuser through its role', async () => {
fetchDbAccess.mockResolvedValue({
...catalog,
principals: [
{ ...catalog.principals[0]!, superuser: false },
{ ...catalog.principals[1]!, superuser: true },
],
});
await accountOf('alice');
expect(screen.getByTestId('access-permission-allow-all').textContent).toMatch(
/Superuser.*Inherited through readonly/
);
});

it('shows no banner for an ordinary account', async () => {
await accountOf('alice');
expect(screen.queryByTestId('access-permission-allow-all')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
RefreshCw,
} from 'lucide-react';
import {
describeAllowAll,
dialectSupportsDbAccess,
findAllowAll,
userManagementSupport,
privilegesForPrincipal,
type DbPrincipal,
Expand Down Expand Up @@ -474,6 +476,7 @@ export const AccessPermissionPanel: React.FC<{
{selected && stage === 'account' && (
<AccountStage
principal={selected}
principals={principals}
privileges={privileges}
dialect={dialect}
onManageUsers={onAddUser}
Expand Down Expand Up @@ -572,11 +575,13 @@ export const AccessPermissionPanel: React.FC<{

const AccountStage: React.FC<{
principal: DbPrincipal;
/** Every principal, so a role-inherited allow-all can be traced. */
principals: readonly DbPrincipal[];
/** The catalog's privileges, so a drop can be described before it is run. */
privileges: readonly DbPrivilege[];
dialect: string;
onManageUsers?: () => void;
}> = ({ principal, privileges, dialect, onManageUsers }) => {
}> = ({ principal, principals, privileges, dialect, onManageUsers }) => {
// Both memoised: this is an unmemoised child of a panel that owns the
// principal filter, so every keystroke re-ran dropSafetyNotes, which walks
// the whole privileges array.
Expand All @@ -592,6 +597,10 @@ const AccountStage: React.FC<{
() => dropSafetyNotes(principal, privileges),
[principal, privileges]
);
const allowAll = useMemo(
() => findAllowAll({ principal: principal.name, principals, privileges, dialect }),
[principal.name, principals, privileges, dialect]
);
const login =
principal.canLogin === true
? 'Can log in'
Expand All @@ -604,6 +613,14 @@ const AccountStage: React.FC<{
Account details from the GRANT catalog. Add, rename, or drop accounts under User
Management — Access generates SQL only.
</p>
{allowAll && (
<p
data-testid="access-permission-allow-all"
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-2.5 py-1.5 text-[11px] font-semibold text-rose-200"
>
{describeAllowAll(allowAll)}
</p>
)}
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[12px]">
<dt className="text-slate-500">Name</dt>
<dd className="font-mono text-slate-100" data-testid="access-permission-account-name">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,62 @@ describe('AccessView — Permission stale catalog', () => {
expect(principals).not.toMatch(/only_on_postgres/);
});
});

describe('AccessView — User Management roles and allow-all', () => {
const catalog = (dialect: string, principals: unknown[]) =>
fetchDbAccess.mockResolvedValue({
dialect,
schema: 'public',
mode: 'native',
support: { mode: 'native', query: true, grant: true, hint: '' },
principals,
privileges: [],
});

beforeEach(() => {
fetchDbAccess.mockReset();
fetchSchemaList.mockReset();
fetchSchemaList.mockResolvedValue(['public']);
});

async function openUsers(connectionId: string, row: string) {
render(<AccessView />);
fireEvent.click(screen.getByTestId('access-tab-users'));
fireEvent.change(screen.getByTestId('access-connection'), { target: { value: connectionId } });
await waitFor(() => expect(screen.getByTestId(`user-row-${row}`)).toBeTruthy());
}

it('puts a new account in the roles picked from the list', async () => {
catalog('postgres', [
{ name: 'readonly', kind: 'role', canLogin: false, memberOf: [], members: [] },
{ name: 'writers', kind: 'role', canLogin: false, memberOf: [], members: [] },
]);
await openUsers('c1', 'readonly');
fireEvent.click(screen.getByTestId('user-add-user'));
fireEvent.change(screen.getByTestId('user-name'), { target: { value: 'analyst' } });
fireEvent.click(screen.getByTestId('user-member-of-item-readonly'));

const sql = screen.getByTestId('user-sql').textContent ?? '';
expect(sql).toMatch(/CREATE ROLE "analyst"/);
expect(sql).toMatch(/GRANT "readonly" TO "analyst";/);
expect(sql).not.toMatch(/writers/);
});

it('tags a superuser in the list', async () => {
catalog('postgres', [
{ name: 'boss', kind: 'user', canLogin: true, memberOf: [], members: [], superuser: true },
{ name: 'alice', kind: 'user', canLogin: true, memberOf: [], members: [], superuser: false },
]);
await openUsers('c1', 'boss');
expect(screen.getByTestId('user-allow-all-boss').textContent).toBe('superuser');
expect(screen.queryByTestId('user-allow-all-alice')).toBeNull();
});

it('drops a MySQL role by its account name, not as name@host@%', async () => {
catalog('mysql', [{ name: 'reader@%', kind: 'role', canLogin: false, memberOf: [], members: [] }]);
await openUsers('c2', 'reader@%');
fireEvent.click(screen.getByTestId('user-row-reader@%'));
fireEvent.click(screen.getByTestId('user-drop-selected'));
expect(screen.getByTestId('user-sql').textContent).toMatch(/DROP ROLE 'reader'@'%';/);
});
});
Loading
Loading