Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/quiet-roles-seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/cli": patch
---

explain existing PostgreSQL tenant roles before database seeding stops

The database seed command now checks for the roles it needs before creating tables. If roles from a previous Logto database remain in the PostgreSQL cluster, the command reports the conflict and explains why dropping the database did not remove them, so an administrator can clean them up safely before retrying.
6 changes: 6 additions & 0 deletions packages/cli/src/commands/database/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DatabasePool } from '@silverhand/slonik';
import type { CommandModule } from 'yargs';

import { createPoolAndDatabaseIfNeeded } from '../../../database.js';
import { assertNoExistingTenantRoles, getDatabaseName } from '../../../queries/database.js';
import { doesConfigsTableExist } from '../../../queries/logto-config.js';
import { consoleLog, oraPromise } from '../../../utils.js';
import { getLatestAlterationTimestamp } from '../alteration/index.js';
Expand All @@ -26,6 +27,11 @@ export const seedByPool = async (
disablePwnedPasswordCheck = false,
}: SeedByPoolOptions = {}
) => {
// Roles left behind by an old installation are a cluster-level precondition, so fail here before
// opening the transaction.
const database = await getDatabaseName(pool, true);
await assertNoExistingTenantRoles(pool, database);

await pool.transaction(async (connection) => {
// Check alteration scripts available in order to insert correct timestamp
const latestTimestamp = await getLatestAlterationTimestamp();
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/queries/database.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createMockPool, createMockQueryResult, sql } from '@silverhand/slonik';
import { beforeEach, describe, expect, it, vi, type MockedFunction } from 'vitest';

import type { QueryType } from '../test-utils.js';
import { expectSqlAssert } from '../test-utils.js';

import { assertNoExistingTenantRoles, getDatabaseName } from './database.js';

const mockQuery: MockedFunction<QueryType> = vi.fn();
const pool = createMockPool({
query: async (query, values) => mockQuery(query, values),
});

beforeEach(() => {
mockQuery.mockReset();
});

describe('getDatabaseName()', () => {
it('returns the current database name and optionally normalizes hyphens', async () => {
mockQuery.mockResolvedValueOnce(createMockQueryResult([{ currentDatabase: 'logto-db' }]));

await expect(getDatabaseName(pool)).resolves.toBe('logto-db');

mockQuery.mockResolvedValueOnce(createMockQueryResult([{ currentDatabase: 'logto-db' }]));

await expect(getDatabaseName(pool, true)).resolves.toBe('logto_db');
});
});

describe('assertNoExistingTenantRoles()', () => {
const database = 'logto';
const roleNames = [
'logto_tenant_logto',
'logto_tenant_logto_default',
'logto_tenant_logto_admin',
];
const expectedSql = sql`
select rolname as "roleName"
from pg_roles
where rolname in (${sql.join(
roleNames.map((roleName) => sql`${roleName}`),
sql`, `
)})
order by rolname
`;

it('resolves when none of the seed roles exist', async () => {
mockQuery.mockImplementationOnce(async (query, values) => {
expectSqlAssert(query, expectedSql.sql);
expect(values).toEqual(roleNames);

return createMockQueryResult([]);
});

await expect(assertNoExistingTenantRoles(pool, database)).resolves.toBeUndefined();
});

it('reports existing roles before the seed can create them', async () => {
mockQuery.mockResolvedValueOnce(createMockQueryResult([{ roleName: 'logto_tenant_logto' }]));

await expect(assertNoExistingTenantRoles(pool, database)).rejects.toThrow(
[
'Cannot seed database "logto" because these PostgreSQL roles already exist:',
' - logto_tenant_logto',
'',
'PostgreSQL roles are cluster-wide and are not removed when a database is dropped.',
'Verify that the roles belong to an old Logto installation, remove them as a PostgreSQL administrator, and run the seed command again.',
].join('\n')
);
expect(mockQuery).toHaveBeenCalledTimes(1);
});
});
40 changes: 40 additions & 0 deletions packages/cli/src/queries/database.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createTenantDatabaseMetadata } from '@logto/core-kit';
import { adminTenantId, defaultTenantId } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';

Expand All @@ -8,3 +10,41 @@ export const getDatabaseName = async (pool: CommonQueryMethods, normalized = fal

return normalized ? currentDatabase.replaceAll('-', '_') : currentDatabase;
};

/**
* Check the roles that a fresh Logto database needs before the seed opens its transaction.
*
* PostgreSQL roles are shared by the whole cluster, so dropping a database does not remove the
* roles that were created for it. Reusing an existing role could change the permissions of another
* Logto installation, therefore the seed command must stop and let an administrator inspect the
* roles instead.
*/
export const assertNoExistingTenantRoles = async (pool: CommonQueryMethods, database: string) => {
// Derive the names from the same helper `createTenant()` uses at seed time, so this check keeps
// matching if the naming scheme ever changes.
const defaultTenant = createTenantDatabaseMetadata(database, defaultTenantId);
const adminTenant = createTenantDatabaseMetadata(database, adminTenantId);
const roleNames = [defaultTenant.parentRole, defaultTenant.role, adminTenant.role];

const existingRoles = await pool.any<{ roleName: string }>(sql`
select rolname as "roleName"
from pg_roles
where rolname in (${sql.join(
roleNames.map((roleName) => sql`${roleName}`),
sql`, `
)})
order by rolname
`);

if (existingRoles.length > 0) {
throw new Error(
[
`Cannot seed database "${database}" because these PostgreSQL roles already exist:`,
...existingRoles.map(({ roleName }) => ` - ${roleName}`),
'',
'PostgreSQL roles are cluster-wide and are not removed when a database is dropped.',
'Verify that the roles belong to an old Logto installation, remove them as a PostgreSQL administrator, and run the seed command again.',
].join('\n')
);
}
};
Loading