From e71e906abf6de244b1b837ebbd6500ee39802755 Mon Sep 17 00:00:00 2001 From: Zyles Date: Tue, 11 Nov 2025 17:26:11 +0100 Subject: [PATCH] feat: add UUID v7 support Add support for UUID v7 as an alternative ID format, configurable per tenant. --- .../cli/src/commands/database/seed/index.ts | 48 +++++++++- .../cli/src/commands/database/seed/tables.ts | 38 ++++++-- .../cli/src/commands/database/seed/utils.ts | 46 ++++++++++ packages/cli/src/commands/install/utils.ts | 14 +++ .../cloud/pages/Main/InvitationList/index.tsx | 7 +- .../TenantInvitationDropdownItem/index.tsx | 7 +- .../src/containers/ConsoleContent/hooks.ts | 5 + .../src/pages/AcceptInvitation/index.tsx | 7 +- .../src/pages/Connectors/Guide/index.tsx | 4 +- .../components/LinkAccountSection/index.tsx | 4 +- .../DeletionConfirmationModal/index.tsx | 16 +++- packages/core/src/env-set/preconditions.ts | 52 ++++++++++- packages/core/src/libraries/user.ts | 4 +- packages/core/src/oidc/adapter.ts | 58 +++++++----- .../src/routes/organization-role/index.ts | 5 +- packages/core/src/utils/SchemaRouter.ts | 5 +- packages/schemas/src/gen/index.ts | 6 +- packages/schemas/src/gen/schema.ts | 28 +++++- packages/schemas/src/gen/types.ts | 2 + packages/schemas/src/gen/utils.ts | 2 + packages/schemas/src/seeds/application.ts | 59 +++++++----- packages/schemas/src/seeds/management-api.ts | 15 ++- packages/schemas/src/types/mapi-proxy.ts | 6 +- packages/schemas/src/types/system.ts | 33 ++++++- .../schemas/src/types/tenant-organization.ts | 49 ++++++++-- packages/schemas/tables/_after_all.sql | 3 + .../schemas/tables/application_secrets.sql | 2 +- .../application_sign_in_experiences.sql | 2 +- ...r_consent_organization_resource_scopes.sql | 4 +- ...ation_user_consent_organization_scopes.sql | 4 +- ...application_user_consent_organizations.sql | 8 +- ...plication_user_consent_resource_scopes.sql | 4 +- .../application_user_consent_user_scopes.sql | 2 +- packages/schemas/tables/applications.sql | 4 +- .../schemas/tables/applications_roles.sql | 8 +- packages/schemas/tables/custom_phrases.sql | 4 +- .../schemas/tables/custom_profile_fields.sql | 4 +- .../schemas/tables/daily_active_users.sql | 6 +- packages/schemas/tables/daily_token_usage.sql | 4 +- packages/schemas/tables/domains.sql | 4 +- packages/schemas/tables/email_templates.sql | 4 +- packages/schemas/tables/hooks.sql | 4 +- .../idp_initiated_saml_sso_sessions.sql | 4 +- packages/schemas/tables/logs.sql | 4 +- .../tables/oidc_session_extensions.sql | 4 +- packages/schemas/tables/one_time_tokens.sql | 4 +- .../organization_application_relations.sql | 6 +- ...organization_invitation_role_relations.sql | 6 +- .../tables/organization_invitations.sql | 10 +- .../tables/organization_jit_email_domains.sql | 4 +- .../schemas/tables/organization_jit_roles.sql | 6 +- .../organization_jit_sso_connectors.sql | 4 +- ...rganization_role_application_relations.sql | 8 +- ...nization_role_resource_scope_relations.sql | 6 +- .../organization_role_scope_relations.sql | 6 +- .../organization_role_user_relations.sql | 8 +- .../schemas/tables/organization_roles.sql | 6 +- .../schemas/tables/organization_scopes.sql | 2 +- .../tables/organization_user_relations.sql | 6 +- packages/schemas/tables/organizations.sql | 4 +- packages/schemas/tables/passcodes.sql | 4 +- .../schemas/tables/personal_access_tokens.sql | 4 +- packages/schemas/tables/resources.sql | 2 +- packages/schemas/tables/roles.sql | 6 +- packages/schemas/tables/roles_scopes.sql | 8 +- .../tables/saml_application_configs.sql | 2 +- .../tables/saml_application_secrets.sql | 6 +- .../tables/saml_application_sessions.sql | 2 +- packages/schemas/tables/scopes.sql | 4 +- ...ret_enterprise_sso_connector_relations.sql | 4 +- .../secret_social_connector_relations.sql | 4 +- packages/schemas/tables/secrets.sql | 6 +- .../schemas/tables/sentinel_activities.sql | 4 +- packages/schemas/tables/service_logs.sql | 4 +- ...o_connector_idp_initiated_auth_configs.sql | 2 +- packages/schemas/tables/subject_tokens.sql | 4 +- .../schemas/tables/user_sso_identities.sql | 6 +- packages/schemas/tables/users.sql | 6 +- packages/schemas/tables/users_roles.sql | 8 +- .../schemas/tables/verification_records.sql | 6 +- .../schemas/tables/verification_statuses.sql | 6 +- packages/shared/package.json | 3 +- packages/shared/src/node/env/GlobalValues.ts | 22 +++++ packages/shared/src/utils/id.test.ts | 65 ++++++++++++- packages/shared/src/utils/id.ts | 92 ++++++++++++++++++- pnpm-lock.yaml | 10 ++ 86 files changed, 791 insertions(+), 188 deletions(-) create mode 100644 packages/cli/src/commands/database/seed/utils.ts diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts index 84042b5ae708..2fa4f2dc259a 100644 --- a/packages/cli/src/commands/database/seed/index.ts +++ b/packages/cli/src/commands/database/seed/index.ts @@ -1,13 +1,16 @@ +import { IdFormat, isIdFormat } from '@logto/shared'; +import { getEnv } from '@silverhand/essentials'; import type { DatabasePool } from '@silverhand/slonik'; import type { CommandModule } from 'yargs'; import { createPoolAndDatabaseIfNeeded } from '../../../database.js'; import { doesConfigsTableExist } from '../../../queries/logto-config.js'; -import { consoleLog, oraPromise } from '../../../utils.js'; +import { consoleLog, isTty, oraPromise } from '../../../utils.js'; import { getLatestAlterationTimestamp } from '../alteration/index.js'; import { getAlterationDirectory } from '../alteration/utils.js'; import { createTables, seedCloud, seedTables, seedTest } from './tables.js'; +import { promptIdFormat } from './utils.js'; export const seedByPool = async ( pool: DatabasePool, @@ -52,6 +55,38 @@ const seedLegacyTestData = async (pool: DatabasePool) => { }); }; +/** + * Resolve the ID format and set it in `process.env.ID_FORMAT` so all seed + * functions can read it via `getIdFormat()` from `@logto/shared`. + * + * Priority: CLI option > ENV variable > interactive prompt > default ('nanoid') + */ +const resolveIdFormat = async (cliIdFormat?: string): Promise => { + // CLI option takes highest priority + if (cliIdFormat) { + process.env.ID_FORMAT = cliIdFormat; + return; + } + + // Check ENV variable (already set in process.env) + const envFormat = getEnv('ID_FORMAT'); + if (envFormat) { + if (!isIdFormat(envFormat)) { + throw new Error( + `Invalid ID_FORMAT environment variable: '${envFormat}'. Must be 'nanoid' or 'uuid'.` + ); + } + return; + } + + // Interactive prompt in TTY mode + if (isTty()) { + process.env.ID_FORMAT = await promptIdFormat(); + } + + // Default: nanoid (getIdFormat() returns 'nanoid' when ID_FORMAT is unset) +}; + const seed: CommandModule< Record, { @@ -60,6 +95,7 @@ const seed: CommandModule< test?: boolean; 'legacy-test-data'?: boolean; 'encrypt-base-role'?: boolean; + 'id-format'?: string; } > = { command: 'seed [type]', @@ -87,8 +123,14 @@ const seed: CommandModule< .option('encrypt-base-role', { describe: 'Seed base role with password', type: 'boolean', + }) + .option('id-format', { + describe: + 'ID format for all entity types (nanoid or uuid). This choice is permanent and cannot be changed after installation. Defaults to ID_FORMAT env variable or nanoid.', + type: 'string', + choices: Object.values(IdFormat), }), - handler: async ({ swe, cloud, test, legacyTestData, encryptBaseRole }) => { + handler: async ({ swe, cloud, test, legacyTestData, encryptBaseRole, idFormat }) => { const pool = await createPoolAndDatabaseIfNeeded(); if (legacyTestData) { @@ -112,6 +154,8 @@ const seed: CommandModule< } try { + await resolveIdFormat(idFormat); + await seedByPool(pool, cloud, test, encryptBaseRole); } catch (error: unknown) { consoleLog.error(error); diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index 17917e427696..94d69c3e8531 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -5,12 +5,13 @@ import { createDefaultAdminConsoleConfig, defaultTenantId, adminTenantId, - defaultManagementApi, + createDefaultManagementApi, createAdminDataInAdminTenant, createMeApiInAdminTenant, createDefaultSignInExperience, createAdminTenantSignInExperience, createDefaultAdminConsoleApplication, + Applications, createCloudApi, createTenantApplicationRole, CloudScope, @@ -19,18 +20,24 @@ import { UsersRoles, LogtoConfigs, SignInExperiences, - Applications, OrganizationUserRelations, getTenantOrganizationId, Users, OrganizationRoleUserRelations, TenantRole, AccountCenters, + Systems, } from '@logto/schemas'; import { getTenantRole } from '@logto/schemas'; import { createDefaultAccountCenter } from '@logto/schemas/lib/seeds/account-center.js'; import { Tenants } from '@logto/schemas/models'; -import { generateStandardId } from '@logto/shared'; +import { + IdFormat, + generateStandardId, + generateStandardSecret, + buildSeedId, + getIdFormat, +} from '@logto/shared'; import type { DatabaseTransactionConnection } from '@silverhand/slonik'; import { sql } from '@silverhand/slonik'; @@ -99,6 +106,9 @@ export const createTables = async ( ]) ); + // Resolve the ${id_format} variable in SQL: native uuid type or varchar(21) for nanoid + const idFormatType = getIdFormat() === IdFormat.Uuid ? 'uuid' : 'varchar(21)'; + const runLifecycleQuery = async ( lifecycle: Lifecycle, parameters: { name?: string; database?: string; password?: string } = {} @@ -125,13 +135,15 @@ export const createTables = async ( ]; const sorted = allQueries.slice().sort(compareQuery); const database = await getDatabaseName(connection, true); - const password = encryptBaseRole ? generateStandardId(32) : ''; + const password = encryptBaseRole ? generateStandardSecret() : ''; await runLifecycleQuery('before_all', { database, password }); /* eslint-disable no-await-in-loop */ for (const [file, query] of sorted) { - await connection.query(sql`${sql.raw(query)}`); + // eslint-disable-next-line no-template-curly-in-string + const resolvedQuery = query.replaceAll('${id_format}', idFormatType); + await connection.query(sql`${sql.raw(resolvedQuery)}`); if (!query.includes('/* no_after_each */')) { await runLifecycleQuery('after_each', { name: file.split('.')[0], database }); @@ -151,7 +163,7 @@ export const seedTables = async ( ) => { await createTenant(connection, defaultTenantId); await seedOidcConfigs(connection, defaultTenantId); - await seedAdminData(connection, defaultManagementApi); + await seedAdminData(connection, createDefaultManagementApi()); /** * Create a pre-configured role for the Logto Management API access @@ -205,6 +217,12 @@ export const seedTables = async ( connection.query(insertInto(createDefaultAdminConsoleApplication(), Applications.table)), connection.query(insertInto(createDefaultAccountCenter(defaultTenantId), AccountCenters.table)), connection.query(insertInto(createDefaultAccountCenter(adminTenantId), AccountCenters.table)), + // Store the chosen ID format permanently in the systems table + connection.query(sql` + insert into ${sql.identifier([Systems.table])} (key, value) + values (${'idFormat'}, ${sql.jsonb({ format: getIdFormat() })}) + on conflict (key) do update set value = excluded.value + `), ]); // The below seed data is for the Logto Cloud only. We put it here for the sake of simplicity. @@ -253,7 +271,7 @@ export const seedTest = async (connection: DatabaseTransactionConnection, forLeg ) ); - const userIds = Object.freeze(['test-1', 'test-2'] as const); + const userIds = Object.freeze([buildSeedId('test-1'), buildSeedId('test-2')] as const); await Promise.all([ connection.query( insertInto({ id: userIds[0], username: 'test1', tenantId: adminTenantId }, Users.table) @@ -290,7 +308,11 @@ export const seedTest = async (connection: DatabaseTransactionConnection, forLeg const addOrganizationMembership = async (userId: string, tenantId: string) => connection.query( insertInto( - { userId, organizationId: getTenantOrganizationId(tenantId), tenantId: adminTenantId }, + { + userId, + organizationId: getTenantOrganizationId(tenantId), + tenantId: adminTenantId, + }, OrganizationUserRelations.table ) ); diff --git a/packages/cli/src/commands/database/seed/utils.ts b/packages/cli/src/commands/database/seed/utils.ts new file mode 100644 index 000000000000..d4cf8945b85a --- /dev/null +++ b/packages/cli/src/commands/database/seed/utils.ts @@ -0,0 +1,46 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; + +import { consoleLog } from '../../../utils.js'; + +/** + * Prompt the user to select ID format for all entity types. + * This is used during database seeding to configure the ID format for the instance. + * + * @returns The selected format string ('nanoid' or 'uuid') + */ +export const promptIdFormat = async (): Promise => { + consoleLog.info( + '\n' + + chalk.bold('ID Format Configuration') + + '\n\n' + + 'Logto supports two ID formats:\n' + + ` ${chalk.green('nanoid')} - Compact, URL-safe IDs (12-21 characters, e.g., "a1b2c3d4e5f6")\n` + + ` ${chalk.magenta('uuid')} - UUID v7 format (36 characters, time-ordered, e.g., "018e8c3a-9d2e-7890-a123-456789abcdef")\n\n` + + chalk.red('Warning:') + + ' This choice is permanent and cannot be changed after installation.\n' + + chalk.yellow('Tip:') + + ' Set the ID_FORMAT environment variable to skip this prompt.\n' + ); + + const { format } = await inquirer.prompt<{ format: string }>({ + type: 'list', + name: 'format', + message: 'Select ID format for all entities (users, organizations, roles):', + choices: [ + { + name: `${chalk.green('nanoid')} - Compact, URL-safe`, + value: 'nanoid', + }, + { + name: `${chalk.magenta('uuid')} - UUID v7`, + value: 'uuid', + }, + ], + default: 'nanoid', + }); + + consoleLog.info(`\n${chalk.bold('Selected ID format:')} ${chalk.green(format)}\n`); + + return format; +}; diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts index 797ce4f875bd..684002d5759e 100644 --- a/packages/cli/src/commands/install/utils.ts +++ b/packages/cli/src/commands/install/utils.ts @@ -4,6 +4,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { isIdFormat } from '@logto/shared'; import { assert } from '@silverhand/essentials'; import chalk from 'chalk'; import { got, RequestError } from 'got'; @@ -24,6 +25,7 @@ import { safeExecSync, } from '../../utils.js'; import { seedByPool } from '../database/seed/index.js'; +import { promptIdFormat } from '../database/seed/utils.js'; const pgRequired = new semver.SemVer('14.0.0'); @@ -164,6 +166,18 @@ export const decompress = async (toPath: string, tarPath: string) => { export const seedDatabase = async (instancePath: string, cloud: boolean) => { try { const pool = await createPoolAndDatabaseIfNeeded(); + + // Resolve ID format: ENV variable > interactive prompt > default (nanoid) + const envFormat = process.env.ID_FORMAT; + if (envFormat && !isIdFormat(envFormat)) { + throw new Error( + `Invalid ID_FORMAT environment variable: '${envFormat}'. Must be 'nanoid' or 'uuid'.` + ); + } + if (!envFormat && isTty()) { + process.env.ID_FORMAT = await promptIdFormat(); + } + await seedByPool(pool, cloud); await pool.end(); } catch (error: unknown) { diff --git a/packages/console/src/cloud/pages/Main/InvitationList/index.tsx b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx index 13e17b42a472..739aca92e041 100644 --- a/packages/console/src/cloud/pages/Main/InvitationList/index.tsx +++ b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx @@ -53,7 +53,12 @@ function InvitationList({ invitations }: Props) { }); const data = await cloudApi.get('/api/tenants'); resetTenants(data); - navigateTenant(getTenantIdFromOrganizationId(organizationId)); + navigateTenant( + getTenantIdFromOrganizationId( + organizationId, + data.map(({ id }) => id) + ) + ); } finally { setIsJoining(false); } diff --git a/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx index 8812f86b50f1..033227f80d73 100644 --- a/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx +++ b/packages/console/src/components/Topbar/TenantSelector/TenantInvitationDropdownItem/index.tsx @@ -43,7 +43,12 @@ function TenantInvitationDropdownItem({ data }: Props) { }); const data = await cloudApi.get('/api/tenants'); resetTenants(data); - navigateTenant(getTenantIdFromOrganizationId(organizationId)); + navigateTenant( + getTenantIdFromOrganizationId( + organizationId, + data.map(({ id }) => id) + ) + ); }} /> diff --git a/packages/console/src/containers/ConsoleContent/hooks.ts b/packages/console/src/containers/ConsoleContent/hooks.ts index c02e05fe59c5..98011e730692 100644 --- a/packages/console/src/containers/ConsoleContent/hooks.ts +++ b/packages/console/src/containers/ConsoleContent/hooks.ts @@ -25,6 +25,11 @@ const useTenantScopeListener = () => { const { scopes, isLoading } = useCurrentTenantScopes(); useEffect(() => { + // Tenant scope listening is only relevant for Cloud, where multi-tenant + // organization-based access control is used. OSS has a single tenant with no scope changes. + if (!isCloud) { + return; + } (async () => { const organizationId = getTenantOrganizationId(currentTenantId); const claims = await getOrganizationTokenClaims(organizationId); diff --git a/packages/console/src/pages/AcceptInvitation/index.tsx b/packages/console/src/pages/AcceptInvitation/index.tsx index d106cb6b392c..52188a3d0b27 100644 --- a/packages/console/src/pages/AcceptInvitation/index.tsx +++ b/packages/console/src/pages/AcceptInvitation/index.tsx @@ -46,7 +46,12 @@ function AcceptInvitation() { const data = await cloudApi.get('/api/tenants'); resetTenants(data); - navigateTenant(getTenantIdFromOrganizationId(organizationId)); + navigateTenant( + getTenantIdFromOrganizationId( + organizationId, + data.map(({ id }) => id) + ) + ); })(); }, [cloudApi, error, invitation, navigateTenant, resetTenants, t]); diff --git a/packages/console/src/pages/Connectors/Guide/index.tsx b/packages/console/src/pages/Connectors/Guide/index.tsx index 984452148f35..54ad2ac20210 100644 --- a/packages/console/src/pages/Connectors/Guide/index.tsx +++ b/packages/console/src/pages/Connectors/Guide/index.tsx @@ -1,7 +1,7 @@ import { isLanguageTag } from '@logto/language-kit'; import { ConnectorType } from '@logto/schemas'; import type { ConnectorFactoryResponse, RequestErrorBody } from '@logto/schemas'; -import { generateStandardId } from '@logto/shared/universal'; +import { generateStandardShortId } from '@logto/shared/universal'; import { conditional } from '@silverhand/essentials'; import i18next from 'i18next'; import { HTTPError } from 'ky'; @@ -45,7 +45,7 @@ type Props = { function Guide({ connector, onClose }: Props) { const { createConnector } = useConnectorApi(); const { navigate } = useTenantPathname(); - const callbackConnectorId = useRef(generateStandardId()); + const callbackConnectorId = useRef(generateStandardShortId()); const [conflictConnectorName, setConflictConnectorName] = useState>(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { diff --git a/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx b/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx index 7d9654366c17..9f2d8efeb518 100644 --- a/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx +++ b/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx @@ -2,7 +2,7 @@ import type { SocialUserInfo } from '@logto/connector-kit'; import { socialUserInfoGuard } from '@logto/connector-kit'; import { Theme } from '@logto/schemas'; import type { ConnectorResponse, UserProfileResponse } from '@logto/schemas'; -import { generateStandardId } from '@logto/shared/universal'; +import { generateStandardShortId } from '@logto/shared/universal'; import type { Optional } from '@silverhand/essentials'; import { appendPath, conditional } from '@silverhand/essentials'; import { useCallback, useMemo } from 'react'; @@ -42,7 +42,7 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) { const getSocialAuthorizationUri = useCallback( async (connectorId: string) => { const adminTenantEndpointUrl = new URL(adminTenantEndpoint); - const state = generateStandardId(8); + const state = generateStandardShortId(); const redirectUri = new URL(`/callback/${connectorId}`, adminTenantEndpointUrl).href; const { redirectTo } = await api .post('me/social/authorization-uri', { json: { connectorId, state, redirectUri } }) diff --git a/packages/console/src/pages/Profile/containers/DeleteAccountModal/components/DeletionConfirmationModal/index.tsx b/packages/console/src/pages/Profile/containers/DeleteAccountModal/components/DeletionConfirmationModal/index.tsx index 332ce24997c2..a34e33cb85ba 100644 --- a/packages/console/src/pages/Profile/containers/DeleteAccountModal/components/DeletionConfirmationModal/index.tsx +++ b/packages/console/src/pages/Profile/containers/DeleteAccountModal/components/DeletionConfirmationModal/index.tsx @@ -19,7 +19,7 @@ type RoleMap = { [key in string]?: string[] }; * Given a list of organization roles from the user's claims, returns a tenant ID - role names map. * A user may have multiple roles in the same tenant. */ -const getRoleMap = (organizationRoles: string[]) => +const getRoleMap = (organizationRoles: string[], knownTenantIds: string[]) => organizationRoles.reduce((accumulator, value) => { const [organizationId, roleName] = value.split(':'); @@ -27,9 +27,10 @@ const getRoleMap = (organizationRoles: string[]) => return accumulator; } - const tenantId = getTenantIdFromOrganizationId(organizationId); - - if (!tenantId) { + let tenantId: string; + try { + tenantId = getTenantIdFromOrganizationId(organizationId, knownTenantIds); + } catch { return accumulator; } @@ -68,7 +69,12 @@ export default function DeletionConfirmationModal({ onClose }: Props) { void fetchRoleMap(); }, [getIdTokenClaims, onClose, t]); - const roleMap = claims && getRoleMap(claims.organization_roles ?? []); + const roleMap = + claims && + getRoleMap( + claims.organization_roles ?? [], + tenants.map(({ id }) => id) + ); const tenantsToDelete = tenants.filter(({ id }) => roleMap?.[id]?.includes(TenantRole.Admin)); const tenantsToQuit = tenants.filter(({ id }) => tenantsToDelete.every(({ id: tenantId }) => tenantId !== id) diff --git a/packages/core/src/env-set/preconditions.ts b/packages/core/src/env-set/preconditions.ts index 6b9a79e8f403..546e3472fa34 100644 --- a/packages/core/src/env-set/preconditions.ts +++ b/packages/core/src/env-set/preconditions.ts @@ -1,5 +1,5 @@ import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js'; -import { ServiceLogs, Systems } from '@logto/schemas'; +import { idFormatDataGuard, ServiceLogs, Systems } from '@logto/schemas'; import { ConsoleLog, isKeyInObject } from '@logto/shared'; import { conditionalString } from '@silverhand/essentials'; import { sql, type CommonQueryMethods, type DatabasePool } from '@silverhand/slonik'; @@ -11,7 +11,7 @@ const consoleLog = new ConsoleLog(chalk.magenta('pre')); export const checkPreconditions = async (pool: DatabasePool) => { checkDeprecations(); - await Promise.all([checkAlterationState(pool), checkRowLevelSecurity(pool)]); + await Promise.all([checkAlterationState(pool), checkRowLevelSecurity(pool), checkIdFormat(pool)]); }; const checkRowLevelSecurity = async (client: CommonQueryMethods) => { @@ -56,6 +56,54 @@ const checkAlterationState = async (pool: CommonQueryMethods) => { throw new Error(`Undeployed database alterations found.`); }; +/** + * Validate the ID format configuration. + * If the database has a locked-in format (stored during seed), it must match the ENV variable. + * If ID_FORMAT is not set, adopt the database value automatically. + * If they conflict, refuse to start. + */ +const checkIdFormat = async (pool: CommonQueryMethods) => { + const envFormat = EnvSet.values.idFormat; + const result = await pool.maybeOne<{ value: unknown }>(sql` + select ${sql.identifier(['value'])} from ${sql.identifier([Systems.table])} + where ${sql.identifier(['key'])} = ${'idFormat'} + `); + + if (!result) { + // No row exists (first start before seed) — skip, the seed process will store it. + return; + } + + const parsed = idFormatDataGuard.safeParse(result.value); + + if (!parsed.success) { + consoleLog.error( + 'Invalid ID format configuration found in database (systems.idFormat). ' + + 'The stored value does not match the expected schema. Please fix or remove this row and try again.' + ); + consoleLog.error(parsed.error.toString()); + throw new Error( + 'Startup aborted due to invalid ID format configuration in the database (systems.idFormat).' + ); + } + + const databaseFormat = parsed.data.format; + + // If env is not set, adopt the database format + if (!envFormat) { + process.env.ID_FORMAT = databaseFormat; + consoleLog.info(`ID format loaded from database: '${databaseFormat}'`); + return; + } + + if (databaseFormat !== envFormat) { + throw new Error( + `ID format mismatch: database is locked to '${databaseFormat}' but ID_FORMAT env is set to '${envFormat}'. ` + + `Once set, the ID format cannot be changed. Update your ID_FORMAT environment variable to '${databaseFormat}'.` + ); + } +}; + const checkDeprecations = () => { if (EnvSet.values.userDefaultRoleNames.length > 0) { consoleLog.warn( diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index ea2cb1dd644a..cefe3db1dc4e 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -5,7 +5,7 @@ import { RoleType, UsersPasswordEncryptionMethod, } from '@logto/schemas'; -import { generateStandardShortId, generateStandardId } from '@logto/shared'; +import { generateStandardId } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; import { deduplicateByKey, condArray } from '@silverhand/essentials'; import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm'; @@ -55,7 +55,7 @@ export const createUserLibrary = (tenantId: string, queries: Queries) => { const generateUserId = async (retries = 500) => pRetry( async () => { - const id = generateStandardShortId(); + const id = generateStandardId(); if (!(await hasUserWithId(id))) { return id; diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index 66be6f254268..946f4e04d500 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -1,9 +1,11 @@ import type { CreateApplication } from '@logto/schemas'; import { - ApplicationType, - accountCenterApplicationId, adminConsoleApplicationId, - demoAppApplicationId, + adminTenantId, + ApplicationType, + defaultTenantId, + getAccountCenterApplicationId, + getDemoAppApplicationId, } from '@logto/schemas'; import { appendPath, tryThat, conditional } from '@silverhand/essentials'; import { addSeconds } from 'date-fns'; @@ -18,28 +20,35 @@ import type Queries from '#src/tenants/Queries.js'; import { getConstantClientMetadata } from './utils.js'; /** - * Append `redirect_uris` and `post_logout_redirect_uris` for Admin Console - * as Admin Console is attached to the admin tenant in OSS and its endpoints are dynamic (from env variable). + * Build client metadata for Admin Console at runtime. + * Admin Console is a built-in application (not stored in the database) whose + * redirect URIs are computed dynamically from environment variables. */ -const transpileMetadata = (clientId: string, data: AllClientMetadata): AllClientMetadata => { - if (clientId !== adminConsoleApplicationId) { - return data; - } - +const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => { const { adminUrlSet, cloudUrlSet } = EnvSet.values; - const urls = [ - ...adminUrlSet.deduplicated().map((url) => appendPath(url, '/console')), - ...cloudUrlSet.deduplicated(), - ]; + const consoleUrls = adminUrlSet.deduplicated().map((url) => appendPath(url, '/console')); + const cloudUrls = cloudUrlSet.deduplicated(); + const allUrls = [...consoleUrls, ...cloudUrls]; + + // Cloud-specific per-tenant callback paths + const cloudTenantCallbacks = cloudUrlSet + .deduplicated() + .flatMap((endpoint) => + [defaultTenantId, adminTenantId].map( + (tenantId) => appendPath(endpoint, tenantId, 'callback').href + ) + ); return { - ...data, + ...getConstantClientMetadata(envSet, ApplicationType.SPA), + client_id: adminConsoleApplicationId, + client_name: 'Admin Console', redirect_uris: [ - ...(data.redirect_uris ?? []), - ...urls.map((url) => appendPath(url, '/callback').href), + ...allUrls.map((url) => appendPath(url, '/callback').href), + ...cloudTenantCallbacks, ], - post_logout_redirect_uris: [...(data.post_logout_redirect_uris ?? []), ...urls.map(String)], + post_logout_redirect_uris: allUrls.map(String), }; }; @@ -50,7 +59,7 @@ const buildDemoAppClientMetadata = (envSet: EnvSet): AllClientMetadata => { return { ...getConstantClientMetadata(envSet, ApplicationType.SPA), - client_id: demoAppApplicationId, + client_id: getDemoAppApplicationId(), client_name: 'Live Preview', redirect_uris: urlStrings, post_logout_redirect_uris: urlStrings, @@ -64,7 +73,7 @@ const buildAccountCenterClientMetadata = (envSet: EnvSet): AllClientMetadata => return { ...getConstantClientMetadata(envSet, ApplicationType.SPA), - client_id: accountCenterApplicationId, + client_id: getAccountCenterApplicationId(), client_name: 'Account Center', redirect_uris: urlStrings, post_logout_redirect_uris: urlStrings, @@ -139,7 +148,7 @@ export default function postgresAdapter( client_secret, client_name, ...getConstantClientMetadata(envSet, type), - ...transpileMetadata(client_id, snakecaseKeys(oidcClientMetadata)), + ...snakecaseKeys(oidcClientMetadata), // `node-oidc-provider` won't camelCase custom parameter keys, so we need to keep the keys camelCased ...customClientMetadata, /* Third-party client scopes are restricted to the app-level enabled user scopes. */ @@ -149,10 +158,13 @@ export default function postgresAdapter( return { upsert: reject, find: async (id) => { - if (id === demoAppApplicationId) { + if (id === adminConsoleApplicationId) { + return buildAdminConsoleClientMetadata(envSet); + } + if (id === getDemoAppApplicationId()) { return buildDemoAppClientMetadata(envSet); } - if (id === accountCenterApplicationId) { + if (id === getAccountCenterApplicationId()) { return buildAccountCenterClientMetadata(envSet); } diff --git a/packages/core/src/routes/organization-role/index.ts b/packages/core/src/routes/organization-role/index.ts index 213f1e24275d..d9bae3b07c12 100644 --- a/packages/core/src/routes/organization-role/index.ts +++ b/packages/core/src/routes/organization-role/index.ts @@ -108,7 +108,10 @@ export default function organizationRoleRoutes( : 'organizationUserRolesLimit' ); - const role = await roles.insert({ id: generateStandardId(), ...data }); + const role = await roles.insert({ + id: generateStandardId(), + ...data, + }); if (organizationScopeIds.length > 0) { await rolesScopes.insert( diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index d399d77d42d4..7cd8b2cb903a 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -118,6 +118,7 @@ type SchemaRouterConfig = { searchFields: SearchOptions['fields']; /** * The length of the ID generated by `generateStandardId()`. + * This parameter is ignored when the ID format is `uuid` (UUIDs have a fixed length). * * @see {@link generateStandardId} for the default length. */ @@ -408,9 +409,11 @@ export default class SchemaRouter< }), this.#assembleQualifiedMiddlewares('post'), async (ctx, next) => { + const id = generateStandardId(idLength); + // eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well with generics ctx.body = await queries.insert({ - id: generateStandardId(idLength), + id, ...ctx.guard.body, } as CreateSchema); this.config.hooks?.afterInsert?.(ctx); diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index 6d81bb6bd8cf..efab924d7e50 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -43,7 +43,11 @@ const generate = async () => { files .filter((file) => file.endsWith('.sql')) .map>(async (file) => { - const paragraph = await fs.readFile(path.join(directory, file), { encoding: 'utf8' }); + const rawSql = await fs.readFile(path.join(directory, file), { encoding: 'utf8' }); + // Resolve ${id_format} variable to default varchar(21) for type generation. + // The @id_format annotation is preserved so parseType() can detect ID columns. + // eslint-disable-next-line no-template-curly-in-string + const paragraph = rawSql.replaceAll('${id_format}', 'varchar(21) /* @id_format */'); // Get statements const statements = paragraph diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts index dfa476463a2e..f0f471640373 100644 --- a/packages/schemas/src/gen/schema.ts +++ b/packages/schemas/src/gen/schema.ts @@ -4,7 +4,7 @@ import { condArray, condString, conditionalString } from '@silverhand/essentials import camelcase from 'camelcase'; import pluralize from 'pluralize'; -import type { TableWithType } from './types.js'; +import type { FieldWithType, TableWithType } from './types.js'; const createTypeRemark = (originalType: string) => [ '', @@ -36,6 +36,18 @@ const printComments = ( ).join('') ); +/** + * For ID columns (marked with ${id_format} in SQL), widen the max length to 36 + * to accommodate both nanoid (21 chars) and UUID (36 chars) formats. + */ +const getEffectiveMaxLength = (field: FieldWithType): number | undefined => { + const { maxLength, isIdFormat } = field; + if (maxLength && isIdFormat) { + return Math.max(maxLength, 36); + } + return maxLength; +}; + export const generateSchema = ({ name, comments, fields }: TableWithType) => { const modelName = pluralize(camelcase(name, { pascalCase: true }), 1); const databaseEntryType = `Create${modelName}`; @@ -76,19 +88,22 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => { ...fields.map( // eslint-disable-next-line complexity - ({ name, type, isArray, isEnum, nullable, hasDefaultValue, tsType, isString, maxLength }) => { + (field) => { + const { name, type, isArray, isEnum, nullable, hasDefaultValue, tsType, isString } = field; if (tsType) { return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( nullable && '.nullable()' )}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`; } + const effectiveMaxLength = getEffectiveMaxLength(field); + return ` ${camelcase(name)}: z.${isEnum ? `nativeEnum(${type})` : `${type}()`}${ // Non-nullable strings should have a min length of 1 conditionalString(isString && !(nullable || name === tenantId) && `.min(1)`) }${ // String types value in DB should have a max length - conditionalString(isString && maxLength && `.max(${maxLength})`) + conditionalString(isString && effectiveMaxLength && `.max(${effectiveMaxLength})`) }${conditionalString(isArray && '.array()')}${conditionalString( nullable && '.nullable()' )}${conditionalString( @@ -102,13 +117,16 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => { ...fields.map( // eslint-disable-next-line complexity - ({ name, type, isArray, isEnum, nullable, tsType, isString, maxLength, hasDefaultValue }) => { + (field) => { + const { name, type, isArray, isEnum, nullable, tsType, isString, hasDefaultValue } = field; if (tsType) { return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( nullable && '.nullable()' )},`; } + const effectiveMaxLength = getEffectiveMaxLength(field); + return ` ${camelcase(name)}: z.${isEnum ? `nativeEnum(${type})` : `${type}()`}${ // Non-nullable strings should have a min length of 1 conditionalString( @@ -116,7 +134,7 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => { ) }${ // String types value in DB should have a max length - conditionalString(isString && maxLength && `.max(${maxLength})`) + conditionalString(isString && effectiveMaxLength && `.max(${effectiveMaxLength})`) }${conditionalString(isArray && '.array()')}${conditionalString( nullable && '.nullable()' )},`; diff --git a/packages/schemas/src/gen/types.ts b/packages/schemas/src/gen/types.ts index 4aec34478053..ea1e9f2c99ff 100644 --- a/packages/schemas/src/gen/types.ts +++ b/packages/schemas/src/gen/types.ts @@ -7,6 +7,8 @@ export type Field = { tsType?: string; isString: boolean; maxLength?: number; + /** Whether this column uses the ${id_format} placeholder (entity ID column). */ + isIdFormat: boolean; hasDefaultValue: boolean; nullable: boolean; isArray: boolean; diff --git a/packages/schemas/src/gen/utils.ts b/packages/schemas/src/gen/utils.ts index 49963bfacdca..0de25cef43b4 100644 --- a/packages/schemas/src/gen/utils.ts +++ b/packages/schemas/src/gen/utils.ts @@ -207,6 +207,7 @@ export const parseType = (tableFieldDefinition: string): Field => { const hasDefaultValue = restLowercased.includes('default'); const nullable = !restLowercased.includes('not null'); const tsType = /\/\* @use (.*) \*\//.exec(restJoined)?.[1]; + const isIdFormat = restJoined.includes('/* @id_format */'); assert( !(!primitiveType && tsType), @@ -222,6 +223,7 @@ export const parseType = (tableFieldDefinition: string): Field => { isString, isArray, maxLength: conditional(isString && parseStringMaxLength(type)), + isIdFormat, customType: conditional(!primitiveType && type), tsType, hasDefaultValue, diff --git a/packages/schemas/src/seeds/application.ts b/packages/schemas/src/seeds/application.ts index c5f9eeb2204b..c0d4bf658867 100644 --- a/packages/schemas/src/seeds/application.ts +++ b/packages/schemas/src/seeds/application.ts @@ -1,4 +1,4 @@ -import { generateStandardId, generateStandardSecret } from '@logto/shared/universal'; +import { generateStandardSecret, generateStandardId } from '@logto/shared/universal'; import type { Application, @@ -10,7 +10,7 @@ import { ApplicationType } from '../db-entries/index.js'; import { adminTenantId } from './tenant.js'; /** - * The fixed application ID for Admin Console. + * The fixed application ID for Admin Console (nanoid format). * * This built-in application does not belong to any tenant in the OSS version. */ @@ -19,6 +19,15 @@ export const adminConsoleApplicationId = 'admin-console'; export const demoAppApplicationId = 'demo-app'; export const accountCenterApplicationId = 'account-center'; +/** + * Built-in application IDs are stable, well-known strings used as OIDC client_id values + * across the codebase (demo-app, integration tests, URL builders). They must NOT be + * converted to UUIDs in uuid mode — they are fixed identifiers, not generated entity IDs. + */ +export const getAdminConsoleApplicationId = (): string => adminConsoleApplicationId; +export const getDemoAppApplicationId = (): string => demoAppApplicationId; +export const getAccountCenterApplicationId = (): string => accountCenterApplicationId; + const buildSpaApplicationData = ( tenantId: string, { @@ -45,51 +54,59 @@ const buildSpaApplicationData = ( customData: {}, }); +export const createDefaultAdminConsoleApplication = (): Readonly => + Object.freeze({ + tenantId: adminTenantId, + id: adminConsoleApplicationId, + name: 'Admin Console', + secret: generateStandardSecret(), + description: 'Logto Admin Console.', + type: ApplicationType.SPA, + oidcClientMetadata: { redirectUris: [], postLogoutRedirectUris: [] }, + }); + export const buildDemoAppDataForTenant = (tenantId: string): Application => buildSpaApplicationData(tenantId, { - id: demoAppApplicationId, + id: getDemoAppApplicationId(), name: 'Live Preview', description: 'Preview for Sign-in Experience.', }); export const buildAccountCenterAppDataForTenant = (tenantId: string): Application => buildSpaApplicationData(tenantId, { - id: accountCenterApplicationId, + id: getAccountCenterApplicationId(), name: 'Account Center', description: 'Placeholder application for Account Center.', }); export type BuiltInApplicationId = typeof demoAppApplicationId | typeof accountCenterApplicationId; -export const isBuiltInApplicationId = ( - applicationId: string -): applicationId is BuiltInApplicationId => - applicationId === demoAppApplicationId || applicationId === accountCenterApplicationId; +export const isBuiltInApplicationId = (applicationId: string): boolean => + applicationId === adminConsoleApplicationId || + applicationId === getDemoAppApplicationId() || + applicationId === getAccountCenterApplicationId(); export const isBuiltInClientId = isBuiltInApplicationId; export const buildBuiltInApplicationDataForTenant = ( tenantId: string, - applicationId: BuiltInApplicationId + applicationId: string ): Application => { - if (applicationId === demoAppApplicationId) { + if (applicationId === adminConsoleApplicationId) { + return buildSpaApplicationData(adminTenantId, { + id: adminConsoleApplicationId, + name: 'Admin Console', + description: 'Logto Admin Console.', + }); + } + + if (applicationId === getDemoAppApplicationId()) { return buildDemoAppDataForTenant(tenantId); } return buildAccountCenterAppDataForTenant(tenantId); }; -export const createDefaultAdminConsoleApplication = (): Readonly => - Object.freeze({ - tenantId: adminTenantId, - id: adminConsoleApplicationId, - name: 'Admin Console', - secret: generateStandardSecret(), - description: 'Logto Admin Console.', - type: ApplicationType.SPA, - oidcClientMetadata: { redirectUris: [], postLogoutRedirectUris: [] }, - }); - export const createTenantMachineToMachineApplication = ( tenantId: string ): Readonly => diff --git a/packages/schemas/src/seeds/management-api.ts b/packages/schemas/src/seeds/management-api.ts index b1e4d962fd9d..597413f07663 100644 --- a/packages/schemas/src/seeds/management-api.ts +++ b/packages/schemas/src/seeds/management-api.ts @@ -1,4 +1,4 @@ -import { generateStandardId } from '@logto/shared/universal'; +import { generateStandardId, buildSeedId } from '@logto/shared/universal'; import { RoleType, @@ -74,6 +74,19 @@ export const defaultManagementApi = Object.freeze({ }, }) satisfies AdminData; +/** + * Create a format-aware version of the default Management API seed data. + * The role.id is converted to a valid UUID when format is 'uuid'. + */ +export const createDefaultManagementApi = (): AdminData => ({ + resource: defaultManagementApi.resource, + scopes: [...defaultManagementApi.scopes], + role: { + ...defaultManagementApi.role, + id: buildSeedId('admin-role'), + }, +}); + export function getManagementApiResourceIndicator( tenantId: TenantId ): `https://${TenantId}.logto.app/api`; diff --git a/packages/schemas/src/types/mapi-proxy.ts b/packages/schemas/src/types/mapi-proxy.ts index e6651ac2b1b8..d590d2eb2155 100644 --- a/packages/schemas/src/types/mapi-proxy.ts +++ b/packages/schemas/src/types/mapi-proxy.ts @@ -13,7 +13,7 @@ * This module provides utilities to manage mapi proxy. */ -import { generateStandardSecret } from '@logto/shared/universal'; +import { generateStandardSecret, buildSeedId } from '@logto/shared/universal'; import { RoleType, @@ -32,7 +32,7 @@ import { adminTenantId } from '../seeds/tenant.js'; export const getMapiProxyRole = (tenantId: string): Readonly => Object.freeze({ tenantId: adminTenantId, - id: `m-${tenantId}`, + id: buildSeedId(`m-${tenantId}`), name: `machine:mapi:${tenantId}`, description: `Machine-to-machine role for accessing Management API of tenant '${tenantId}'.`, type: RoleType.MachineToMachine, @@ -49,7 +49,7 @@ export const getMapiProxyRole = (tenantId: string): Readonly => export const getMapiProxyM2mApp = (tenantId: string): Readonly => Object.freeze({ tenantId: adminTenantId, - id: `m-${tenantId}`, + id: buildSeedId(`m-${tenantId}`), secret: generateStandardSecret(32), name: `Management API access for ${tenantId}`, description: `Machine-to-machine app for accessing Management API of tenant '${tenantId}'.`, diff --git a/packages/schemas/src/types/system.ts b/packages/schemas/src/types/system.ts index 564199c03f06..cc709356d65d 100644 --- a/packages/schemas/src/types/system.ts +++ b/packages/schemas/src/types/system.ts @@ -1,3 +1,4 @@ +import { IdFormat } from '@logto/shared/universal'; import type { ZodType } from 'zod'; import { z } from 'zod'; @@ -212,26 +213,50 @@ export const cloudflareGuard: Readonly<{ [CloudflareKey.CustomJwtWorkerConfig]: customJwtWorkerConfigGuard, }); +// ID format configuration +export enum IdFormatKey { + IdFormat = 'idFormat', +} + +export const idFormatDataGuard = z.object({ + format: z.nativeEnum(IdFormat), +}); + +export type IdFormatData = z.infer; + +export type IdFormatType = { + [IdFormatKey.IdFormat]: IdFormatData; +}; + +export const idFormatGuard: Readonly<{ + [key in IdFormatKey]: z.ZodType; +}> = Object.freeze({ + [IdFormatKey.IdFormat]: idFormatDataGuard, +}); + // Summary export type SystemKey = | AlterationStateKey | StorageProviderKey | DemoSocialKey | CloudflareKey - | EmailServiceProviderKey; + | EmailServiceProviderKey + | IdFormatKey; export type SystemType = | AlterationStateType | StorageProviderType | DemoSocialType | CloudflareType - | EmailServiceProviderType; + | EmailServiceProviderType + | IdFormatType; export type SystemGuard = typeof alterationStateGuard & typeof storageProviderGuard & typeof demoSocialGuard & typeof cloudflareGuard & - typeof emailServiceProviderGuard; + typeof emailServiceProviderGuard & + typeof idFormatGuard; export const systemKeys: readonly SystemKey[] = Object.freeze([ ...Object.values(AlterationStateKey), @@ -239,6 +264,7 @@ export const systemKeys: readonly SystemKey[] = Object.freeze([ ...Object.values(DemoSocialKey), ...Object.values(CloudflareKey), ...Object.values(EmailServiceProviderKey), + ...Object.values(IdFormatKey), ]); export const systemGuards: SystemGuard = Object.freeze({ @@ -247,4 +273,5 @@ export const systemGuards: SystemGuard = Object.freeze({ ...demoSocialGuard, ...cloudflareGuard, ...emailServiceProviderGuard, + ...idFormatGuard, }); diff --git a/packages/schemas/src/types/tenant-organization.ts b/packages/schemas/src/types/tenant-organization.ts index b6de216e86e0..3b8b323fa4bf 100644 --- a/packages/schemas/src/types/tenant-organization.ts +++ b/packages/schemas/src/types/tenant-organization.ts @@ -7,6 +7,8 @@ * This module provides utilities to manage tenant organizations. */ +import { buildSeedId } from '@logto/shared/universal'; + import { RoleType, type CreateOrganization, @@ -15,16 +17,47 @@ import { } from '../db-entries/index.js'; import { adminTenantId } from '../seeds/tenant.js'; -/** Given a tenant ID, return the corresponding organization ID in the admin tenant. */ -export const getTenantOrganizationId = (tenantId: string) => `t-${tenantId}`; +/** + * Given a tenant ID, return the corresponding organization ID in the admin tenant. + * + * In nanoid mode, this returns `t-${tenantId}` (human-readable). + * In uuid mode, this returns a deterministic UUID v5 derived from `t-${tenantId}`. + */ +export const getTenantOrganizationId = (tenantId: string) => buildSeedId(`t-${tenantId}`); + +/** + * Given an admin tenant organization ID, return the corresponding user tenant ID. + * + * In nanoid mode, the organization ID has a `t-` prefix that can be stripped directly. + * In uuid mode, the organization ID is a UUID v5 hash, so we check against known tenant IDs + * by computing the forward mapping and comparing. + * + * @param organizationId - The organization ID to look up. + * @param knownTenantIds - The list of known tenant IDs to search through. Required in uuid mode. + */ +export const getTenantIdFromOrganizationId = ( + organizationId: string, + knownTenantIds?: string[] +): string => { + // Fast path: nanoid mode uses the reversible `t-` prefix convention. + if (organizationId.startsWith('t-')) { + return organizationId.slice(2); + } -/** Given an admin tenant organization ID, check the format and return the corresponding user tenant ID. */ -export const getTenantIdFromOrganizationId = (organizationId: string) => { - if (!organizationId.startsWith('t-')) { - throw new Error(`Invalid admin tenant organization ID: ${organizationId}`); + // UUID mode: the organization ID is a UUID v5 hash, so we need to find the tenant ID + // by computing the forward mapping for each known tenant. + if (knownTenantIds) { + for (const tenantId of knownTenantIds) { + if (getTenantOrganizationId(tenantId) === organizationId) { + return tenantId; + } + } } - return organizationId.slice(2); + throw new Error( + `Cannot resolve tenant ID from organization ID: ${organizationId}. ` + + 'In UUID mode, you must provide knownTenantIds to perform the reverse lookup.' + ); }; /** @@ -157,7 +190,7 @@ const tenantRoleDescriptions: Readonly> = Object.free export const getTenantRole = (role: TenantRole): Readonly => Object.freeze({ tenantId: adminTenantId, - id: role, + id: buildSeedId(role), name: role, description: tenantRoleDescriptions[role], type: RoleType.User, diff --git a/packages/schemas/tables/_after_all.sql b/packages/schemas/tables/_after_all.sql index b194421f6e83..788c8e10c373 100644 --- a/packages/schemas/tables/_after_all.sql +++ b/packages/schemas/tables/_after_all.sql @@ -1,5 +1,8 @@ /* This SQL will run after all other queries. */ +---- Grant schema usage (needed when schema is recreated) ---- +grant usage on schema public to logto_tenant_${database}; + ---- Grant CRUD access to the group ---- grant select, insert, update, delete on all tables diff --git a/packages/schemas/tables/application_secrets.sql b/packages/schemas/tables/application_secrets.sql index 406764f80184..ee3044d00f85 100644 --- a/packages/schemas/tables/application_secrets.sql +++ b/packages/schemas/tables/application_secrets.sql @@ -4,7 +4,7 @@ create table application_secrets ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The name of the secret. Should be unique within the application. */ name varchar(256) not null, diff --git a/packages/schemas/tables/application_sign_in_experiences.sql b/packages/schemas/tables/application_sign_in_experiences.sql index d98d8f6743f7..df7dab066310 100644 --- a/packages/schemas/tables/application_sign_in_experiences.sql +++ b/packages/schemas/tables/application_sign_in_experiences.sql @@ -4,7 +4,7 @@ create table application_sign_in_experiences ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, color jsonb /* @use PartialColor */ not null default '{}'::jsonb, branding jsonb /* @use Branding */ not null default '{}'::jsonb, diff --git a/packages/schemas/tables/application_user_consent_organization_resource_scopes.sql b/packages/schemas/tables/application_user_consent_organization_resource_scopes.sql index c72b9338663f..00992ae5d8e2 100644 --- a/packages/schemas/tables/application_user_consent_organization_resource_scopes.sql +++ b/packages/schemas/tables/application_user_consent_organization_resource_scopes.sql @@ -9,10 +9,10 @@ create table application_user_consent_organization_resource_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the application. */ - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The globally unique identifier of the resource scope. */ - scope_id varchar(21) not null + scope_id varchar(36) not null references scopes (id) on update cascade on delete cascade, primary key (application_id, scope_id) ); diff --git a/packages/schemas/tables/application_user_consent_organization_scopes.sql b/packages/schemas/tables/application_user_consent_organization_scopes.sql index cec70ecad2c0..0435210edd22 100644 --- a/packages/schemas/tables/application_user_consent_organization_scopes.sql +++ b/packages/schemas/tables/application_user_consent_organization_scopes.sql @@ -5,10 +5,10 @@ create table application_user_consent_organization_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the application. */ - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The globally unique identifier of the organization scope. */ - organization_scope_id varchar(21) not null + organization_scope_id varchar(36) not null references organization_scopes (id) on update cascade on delete cascade, primary key (application_id, organization_scope_id) ); diff --git a/packages/schemas/tables/application_user_consent_organizations.sql b/packages/schemas/tables/application_user_consent_organizations.sql index d347515167b1..dff44cc92f1d 100644 --- a/packages/schemas/tables/application_user_consent_organizations.sql +++ b/packages/schemas/tables/application_user_consent_organizations.sql @@ -1,13 +1,15 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ /** The relations between applications, users and organizations. A relation means that a user has consented to an application to access data in an organization. */ create table application_user_consent_organizations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, - organization_id varchar(21) not null, - user_id varchar(21) not null, + organization_id ${id_format} not null, + user_id ${id_format} not null, primary key (tenant_id, application_id, organization_id, user_id), /** User's consent to an application should be synchronized with the user's membership in the organization. */ foreign key (tenant_id, organization_id, user_id) diff --git a/packages/schemas/tables/application_user_consent_resource_scopes.sql b/packages/schemas/tables/application_user_consent_resource_scopes.sql index da0830bcab67..6f668337abb6 100644 --- a/packages/schemas/tables/application_user_consent_resource_scopes.sql +++ b/packages/schemas/tables/application_user_consent_resource_scopes.sql @@ -5,10 +5,10 @@ create table application_user_consent_resource_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the application. */ - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The globally unique identifier of the resource scope. */ - scope_id varchar(21) not null + scope_id varchar(36) not null references scopes (id) on update cascade on delete cascade, primary key (application_id, scope_id) ); diff --git a/packages/schemas/tables/application_user_consent_user_scopes.sql b/packages/schemas/tables/application_user_consent_user_scopes.sql index 6311eae32e83..3222dda8d986 100644 --- a/packages/schemas/tables/application_user_consent_user_scopes.sql +++ b/packages/schemas/tables/application_user_consent_user_scopes.sql @@ -5,7 +5,7 @@ create table application_user_consent_user_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the application. */ - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The unique UserScope enum value @see (@logto/core-kit/open-id.js) for more details */ user_scope varchar(64) not null, diff --git a/packages/schemas/tables/applications.sql b/packages/schemas/tables/applications.sql index 0c7c1fa2c438..2061beaf4d4c 100644 --- a/packages/schemas/tables/applications.sql +++ b/packages/schemas/tables/applications.sql @@ -5,7 +5,7 @@ create type application_type as enum ('Native', 'SPA', 'Traditional', 'MachineTo create table applications ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id varchar(36) not null, name varchar(256) not null, /** @deprecated The internal client secret. Note it is only used for internal validation, and the actual secret should be stored in the `application_secrets` table. You should NOT use it unless you are sure what you are doing. */ secret varchar(64) not null, @@ -40,7 +40,7 @@ create unique index applications__protected_app_metadata_custom_domain ); create function check_application_type( - application_id varchar(21), + application_id varchar(36), variadic target_type application_type[] ) returns boolean as $$ begin diff --git a/packages/schemas/tables/applications_roles.sql b/packages/schemas/tables/applications_roles.sql index 7e0b3a51a7fa..cc48997223ea 100644 --- a/packages/schemas/tables/applications_roles.sql +++ b/packages/schemas/tables/applications_roles.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table applications_roles ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - application_id varchar(21) not null + id ${id_format} not null, + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, - role_id varchar(21) not null + role_id ${id_format} not null references roles (id) on update cascade on delete cascade, primary key (id), constraint applications_roles__application_id_role_id diff --git a/packages/schemas/tables/custom_phrases.sql b/packages/schemas/tables/custom_phrases.sql index 3fd6c1ca5339..3bb50cc2a8d6 100644 --- a/packages/schemas/tables/custom_phrases.sql +++ b/packages/schemas/tables/custom_phrases.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table custom_phrases ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, language_tag varchar(16) not null, translation jsonb /* @use Translation */ not null, primary key (id), diff --git a/packages/schemas/tables/custom_profile_fields.sql b/packages/schemas/tables/custom_profile_fields.sql index f8c0ad11e96e..0131e9b64929 100644 --- a/packages/schemas/tables/custom_profile_fields.sql +++ b/packages/schemas/tables/custom_profile_fields.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table custom_profile_fields ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, name varchar(128) not null, type varchar(128) not null /* @use CustomProfileFieldType */, label varchar(128) not null default '', diff --git a/packages/schemas/tables/daily_active_users.sql b/packages/schemas/tables/daily_active_users.sql index 8abd9c9156eb..1eea9b627e03 100644 --- a/packages/schemas/tables/daily_active_users.sql +++ b/packages/schemas/tables/daily_active_users.sql @@ -1,8 +1,10 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table daily_active_users ( - id varchar(21) not null, + id ${id_format} not null, tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - user_id varchar(21) not null, + user_id ${id_format} not null, date timestamptz not null default (now()), primary key (id), constraint daily_active_users__user_id_date diff --git a/packages/schemas/tables/daily_token_usage.sql b/packages/schemas/tables/daily_token_usage.sql index 4975c06d41a4..75717a992b9b 100644 --- a/packages/schemas/tables/daily_token_usage.sql +++ b/packages/schemas/tables/daily_token_usage.sql @@ -1,5 +1,7 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table daily_token_usage ( - id varchar(21) not null, + id ${id_format} not null, tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, usage bigint not null default(0), diff --git a/packages/schemas/tables/domains.sql b/packages/schemas/tables/domains.sql index 9cdc00b4f358..590103d79405 100644 --- a/packages/schemas/tables/domains.sql +++ b/packages/schemas/tables/domains.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table domains ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, domain varchar(256) not null, status varchar(32) /* @use DomainStatus */ not null default('PendingVerification'), error_message varchar(1024), diff --git a/packages/schemas/tables/email_templates.sql b/packages/schemas/tables/email_templates.sql index 426b3b22dd6d..601935f98bc1 100644 --- a/packages/schemas/tables/email_templates.sql +++ b/packages/schemas/tables/email_templates.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table email_templates ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, language_tag varchar(16) not null, template_type varchar(64) /* @use TemplateType */ not null, details jsonb /* @use EmailTemplateDetails */ not null, diff --git a/packages/schemas/tables/hooks.sql b/packages/schemas/tables/hooks.sql index c3e62a87eb6f..4fefcc575844 100644 --- a/packages/schemas/tables/hooks.sql +++ b/packages/schemas/tables/hooks.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table hooks ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, name varchar(256) not null default '', event varchar(128) /* @use HookEvent */, events jsonb /* @use HookEvents */ not null default '[]'::jsonb, diff --git a/packages/schemas/tables/idp_initiated_saml_sso_sessions.sql b/packages/schemas/tables/idp_initiated_saml_sso_sessions.sql index 1f5a886d50b0..59774e8150f7 100644 --- a/packages/schemas/tables/idp_initiated_saml_sso_sessions.sql +++ b/packages/schemas/tables/idp_initiated_saml_sso_sessions.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table idp_initiated_saml_sso_sessions ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the assertion record. */ - id varchar(21) not null, + id ${id_format} not null, /** The identifier of the SAML SSO connector. */ connector_id varchar(128) not null references sso_connectors (id) on update cascade on delete cascade, diff --git a/packages/schemas/tables/logs.sql b/packages/schemas/tables/logs.sql index 7cf195b47ae6..a8d3a9f48f63 100644 --- a/packages/schemas/tables/logs.sql +++ b/packages/schemas/tables/logs.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table logs ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, key varchar(128) not null, payload jsonb /* @use LogContextPayload */ not null default '{}'::jsonb, created_at timestamptz not null default (now()), diff --git a/packages/schemas/tables/oidc_session_extensions.sql b/packages/schemas/tables/oidc_session_extensions.sql index e9e97e78c87f..96ea76f24e97 100644 --- a/packages/schemas/tables/oidc_session_extensions.sql +++ b/packages/schemas/tables/oidc_session_extensions.sql @@ -1,10 +1,12 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table oidc_session_extensions ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, session_uid varchar(128) not null, - account_id varchar(12) not null + account_id ${id_format} not null references users (id) on update cascade on delete cascade, last_submission jsonb /* @use JsonObject */ not null default '{}'::jsonb, created_at timestamptz not null default(now()), diff --git a/packages/schemas/tables/one_time_tokens.sql b/packages/schemas/tables/one_time_tokens.sql index 200fb647a50f..5fef9fa3f49c 100644 --- a/packages/schemas/tables/one_time_tokens.sql +++ b/packages/schemas/tables/one_time_tokens.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table one_time_tokens ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, email varchar(128) not null, token varchar(256) not null, context jsonb /* @use OneTimeTokenContext */ not null default '{}'::jsonb, diff --git a/packages/schemas/tables/organization_application_relations.sql b/packages/schemas/tables/organization_application_relations.sql index 1bd6db0363e6..20f5bd18e8fc 100644 --- a/packages/schemas/tables/organization_application_relations.sql +++ b/packages/schemas/tables/organization_application_relations.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The relations between organizations and applications. It indicates membership of applications in organizations. For now only machine-to-machine applications are supported. */ create table organization_application_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, primary key (tenant_id, organization_id, application_id), constraint application_type diff --git a/packages/schemas/tables/organization_invitation_role_relations.sql b/packages/schemas/tables/organization_invitation_role_relations.sql index 8e97ae988996..e417db10ba27 100644 --- a/packages/schemas/tables/organization_invitation_role_relations.sql +++ b/packages/schemas/tables/organization_invitation_role_relations.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 4 */ /** The organization roles that will be assigned to a user when they accept an invitation. */ @@ -5,10 +7,10 @@ create table organization_invitation_role_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The ID of the invitation. */ - organization_invitation_id varchar(21) not null + organization_invitation_id ${id_format} not null references organization_invitations (id) on update cascade on delete cascade, /** The ID of the organization role. */ - organization_role_id varchar(21) not null + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, primary key (tenant_id, organization_invitation_id, organization_role_id) ); diff --git a/packages/schemas/tables/organization_invitations.sql b/packages/schemas/tables/organization_invitations.sql index ebe4320e4ab3..9c232e877032 100644 --- a/packages/schemas/tables/organization_invitations.sql +++ b/packages/schemas/tables/organization_invitations.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ create type organization_invitation_status as enum ('Pending', 'Accepted', 'Expired', 'Revoked'); @@ -7,17 +9,17 @@ create table organization_invitations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The unique identifier of the invitation. */ - id varchar(21) not null, + id ${id_format} not null, /** The user ID who sent the invitation. */ - inviter_id varchar(21) + inviter_id ${id_format} references users (id) on update cascade on delete cascade, /** The email address or other identifier of the invitee. */ invitee varchar(256) not null, /** The user ID of who accepted the invitation. */ - accepted_user_id varchar(21) + accepted_user_id ${id_format} references users (id) on update cascade on delete cascade, /** The ID of the organization to which the invitee is invited. */ - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, /** The status of the invitation. */ status organization_invitation_status not null, diff --git a/packages/schemas/tables/organization_jit_email_domains.sql b/packages/schemas/tables/organization_jit_email_domains.sql index 94b3e154d8c8..76f20acfd4c4 100644 --- a/packages/schemas/tables/organization_jit_email_domains.sql +++ b/packages/schemas/tables/organization_jit_email_domains.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The email domains that will automatically assign users into an organization when they sign up or are added through the Management API. */ @@ -5,7 +7,7 @@ create table organization_jit_email_domains ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The ID of the organization. */ - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, /** The email domain that will be automatically provisioned. */ email_domain varchar(128) not null, diff --git a/packages/schemas/tables/organization_jit_roles.sql b/packages/schemas/tables/organization_jit_roles.sql index c5ac603f92af..054136a140d8 100644 --- a/packages/schemas/tables/organization_jit_roles.sql +++ b/packages/schemas/tables/organization_jit_roles.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The organization roles that will be automatically provisioned to users when they join an organization through JIT. */ @@ -5,10 +7,10 @@ create table organization_jit_roles ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The ID of the organization. */ - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, /** The organization role ID that will be automatically provisioned. */ - organization_role_id varchar(21) not null + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, primary key (tenant_id, organization_id, organization_role_id) ); diff --git a/packages/schemas/tables/organization_jit_sso_connectors.sql b/packages/schemas/tables/organization_jit_sso_connectors.sql index 9ef7fb5ba087..46dd6604c76d 100644 --- a/packages/schemas/tables/organization_jit_sso_connectors.sql +++ b/packages/schemas/tables/organization_jit_sso_connectors.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The enterprise SSO connectors that will automatically assign users into an organization when they are authenticated via the SSO connector for the first time. */ @@ -5,7 +7,7 @@ create table organization_jit_sso_connectors ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The ID of the organization. */ - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, sso_connector_id varchar(128) not null references sso_connectors (id) on update cascade on delete cascade, diff --git a/packages/schemas/tables/organization_role_application_relations.sql b/packages/schemas/tables/organization_role_application_relations.sql index c3acc7b943ba..4969bf2082c7 100644 --- a/packages/schemas/tables/organization_role_application_relations.sql +++ b/packages/schemas/tables/organization_role_application_relations.sql @@ -1,13 +1,15 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ /** The relations between organizations, organization roles, and applications. A relation means that an application has a role in an organization. */ create table organization_role_application_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_id varchar(21) not null, - organization_role_id varchar(21) not null + organization_id ${id_format} not null, + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, - application_id varchar(21) not null, + application_id varchar(36) not null, primary key (tenant_id, organization_id, organization_role_id, application_id), /** Application's roles in an organization should be synchronized with the application's membership in the organization. */ foreign key (tenant_id, organization_id, application_id) diff --git a/packages/schemas/tables/organization_role_resource_scope_relations.sql b/packages/schemas/tables/organization_role_resource_scope_relations.sql index 4de9bfb7af9b..5f723772f9a6 100644 --- a/packages/schemas/tables/organization_role_resource_scope_relations.sql +++ b/packages/schemas/tables/organization_role_resource_scope_relations.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ /** The relations between organization roles and resource scopes (normal scopes). It indicates which resource scopes are available to which organization roles. */ create table organization_role_resource_scope_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_role_id varchar(21) not null + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, - scope_id varchar(21) not null + scope_id varchar(36) not null references scopes (id) on update cascade on delete cascade, primary key (tenant_id, organization_role_id, scope_id) ); diff --git a/packages/schemas/tables/organization_role_scope_relations.sql b/packages/schemas/tables/organization_role_scope_relations.sql index e9dd67f3c591..ad16fd277bdf 100644 --- a/packages/schemas/tables/organization_role_scope_relations.sql +++ b/packages/schemas/tables/organization_role_scope_relations.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The relations between organization roles and organization scopes. It indicates which organization scopes are available to which organization roles. */ create table organization_role_scope_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_role_id varchar(21) not null + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, - organization_scope_id varchar(21) not null + organization_scope_id varchar(36) not null references organization_scopes (id) on update cascade on delete cascade, primary key (tenant_id, organization_role_id, organization_scope_id) ); diff --git a/packages/schemas/tables/organization_role_user_relations.sql b/packages/schemas/tables/organization_role_user_relations.sql index a432704effb0..65e3902266c0 100644 --- a/packages/schemas/tables/organization_role_user_relations.sql +++ b/packages/schemas/tables/organization_role_user_relations.sql @@ -1,13 +1,15 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ /** The relations between organizations, organization roles, and users. A relation means that a user has a role in an organization. */ create table organization_role_user_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_id varchar(21) not null, - organization_role_id varchar(21) not null + organization_id ${id_format} not null, + organization_role_id ${id_format} not null references organization_roles (id) on update cascade on delete cascade, - user_id varchar(21) not null, + user_id ${id_format} not null, primary key (tenant_id, organization_id, organization_role_id, user_id), /** User's roles in an organization should be synchronized with the user's membership in the organization. */ foreign key (tenant_id, organization_id, user_id) diff --git a/packages/schemas/tables/organization_roles.sql b/packages/schemas/tables/organization_roles.sql index b76cb21af6a9..eb41ff6e6427 100644 --- a/packages/schemas/tables/organization_roles.sql +++ b/packages/schemas/tables/organization_roles.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 1.1 */ /** The roles defined by the organization template. */ @@ -5,7 +7,7 @@ create table organization_roles ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the organization role. */ - id varchar(21) not null, + id ${id_format} not null, /** The organization role's name, unique within the organization template. */ name varchar(128) not null, /** A brief description of the organization role. */ @@ -23,7 +25,7 @@ create index organization_roles__id create index organization_roles__type on organization_roles (tenant_id, type); -create function check_organization_role_type(role_id varchar(21), target_type role_type) returns boolean as +create function check_organization_role_type(role_id ${id_format}, target_type role_type) returns boolean as $$ begin return (select type from organization_roles where id = role_id) = target_type; end; $$ language plpgsql set search_path = public; diff --git a/packages/schemas/tables/organization_scopes.sql b/packages/schemas/tables/organization_scopes.sql index 5dbc17b6ab87..b646527fa8b8 100644 --- a/packages/schemas/tables/organization_scopes.sql +++ b/packages/schemas/tables/organization_scopes.sql @@ -5,7 +5,7 @@ create table organization_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the organization scope. */ - id varchar(21) not null, + id varchar(36) not null, /** The organization scope's name, unique within the organization template. */ name varchar(128) not null, /** A brief description of the organization scope. */ diff --git a/packages/schemas/tables/organization_user_relations.sql b/packages/schemas/tables/organization_user_relations.sql index a6d1a91be4bf..f15784851d15 100644 --- a/packages/schemas/tables/organization_user_relations.sql +++ b/packages/schemas/tables/organization_user_relations.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ /** The relations between organizations and users. It indicates membership of users in organizations. */ create table organization_user_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - organization_id varchar(21) not null + organization_id ${id_format} not null references organizations (id) on update cascade on delete cascade, - user_id varchar(21) not null + user_id ${id_format} not null references users (id) on update cascade on delete cascade, primary key (tenant_id, organization_id, user_id), constraint organization_user_relations__user_id__fk diff --git a/packages/schemas/tables/organizations.sql b/packages/schemas/tables/organizations.sql index 5872d4729ded..b6431993c076 100644 --- a/packages/schemas/tables/organizations.sql +++ b/packages/schemas/tables/organizations.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 1 */ /** Organizations defined by [RFC 0001](https://github.com/logto-io/rfcs/blob/HEAD/active/0001-organization.md). */ @@ -5,7 +7,7 @@ create table organizations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the organization. */ - id varchar(21) not null, + id ${id_format} not null, /** The organization's name for display. */ name varchar(128) not null, /** A brief description of the organization. */ diff --git a/packages/schemas/tables/passcodes.sql b/packages/schemas/tables/passcodes.sql index a5d6be42ec5f..f59286ea06b7 100644 --- a/packages/schemas/tables/passcodes.sql +++ b/packages/schemas/tables/passcodes.sql @@ -1,7 +1,9 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table passcodes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, interaction_jti varchar(128), phone varchar(32), email varchar(128), diff --git a/packages/schemas/tables/personal_access_tokens.sql b/packages/schemas/tables/personal_access_tokens.sql index 6f8fb39ee161..081389d0bac7 100644 --- a/packages/schemas/tables/personal_access_tokens.sql +++ b/packages/schemas/tables/personal_access_tokens.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table personal_access_tokens ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - user_id varchar(21) not null + user_id ${id_format} not null references users (id) on update cascade on delete cascade, /** The name of the secret. Should be unique within the user. */ name varchar(256) not null, diff --git a/packages/schemas/tables/resources.sql b/packages/schemas/tables/resources.sql index 2ea36a0f9e9c..3b668b55af73 100644 --- a/packages/schemas/tables/resources.sql +++ b/packages/schemas/tables/resources.sql @@ -3,7 +3,7 @@ create table resources ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id varchar(36) not null, name text not null, indicator text not null, /* resource indicator also used as audience */ is_default boolean not null default (false), diff --git a/packages/schemas/tables/roles.sql b/packages/schemas/tables/roles.sql index 419fdc21e88b..779a0d622f95 100644 --- a/packages/schemas/tables/roles.sql +++ b/packages/schemas/tables/roles.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 1 */ create type role_type as enum ('User', 'MachineToMachine'); @@ -5,7 +7,7 @@ create type role_type as enum ('User', 'MachineToMachine'); create table roles ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, name varchar(128) not null, description varchar(128) not null, type role_type not null default 'User', @@ -22,7 +24,7 @@ create index roles__id create index roles__type on roles (tenant_id, type); -create function public.check_role_type(role_id varchar(21), target_type role_type) returns boolean as +create function public.check_role_type(role_id ${id_format}, target_type role_type) returns boolean as $$ begin return (select type from public.roles where id = role_id) = target_type; end; $$ language plpgsql; diff --git a/packages/schemas/tables/roles_scopes.sql b/packages/schemas/tables/roles_scopes.sql index 2b61b7612473..2430440c0b65 100644 --- a/packages/schemas/tables/roles_scopes.sql +++ b/packages/schemas/tables/roles_scopes.sql @@ -1,10 +1,12 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table roles_scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - role_id varchar(21) not null + id ${id_format} not null, + role_id ${id_format} not null references roles (id) on update cascade on delete cascade, - scope_id varchar(21) not null + scope_id varchar(36) not null references scopes (id) on update cascade on delete cascade, primary key (id), constraint roles_scopes__role_id_scope_id diff --git a/packages/schemas/tables/saml_application_configs.sql b/packages/schemas/tables/saml_application_configs.sql index 809e4203a11c..96653c5a985d 100644 --- a/packages/schemas/tables/saml_application_configs.sql +++ b/packages/schemas/tables/saml_application_configs.sql @@ -2,7 +2,7 @@ /** The SAML application config and SAML-type application have a one-to-one correspondence: 1. a SAML-type application can only have one SAML application config. (CANNOT use "semicolon" in comments, since it indicates the end of query.) 2. a SAML application config can only configure one SAML-type application. */ create table saml_application_configs ( - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, diff --git a/packages/schemas/tables/saml_application_secrets.sql b/packages/schemas/tables/saml_application_secrets.sql index 0694e0e4f067..c600c23d4133 100644 --- a/packages/schemas/tables/saml_application_secrets.sql +++ b/packages/schemas/tables/saml_application_secrets.sql @@ -1,10 +1,12 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table saml_application_secrets ( - id varchar(21) not null, + id ${id_format} not null, tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, private_key text not null, certificate text not null, diff --git a/packages/schemas/tables/saml_application_sessions.sql b/packages/schemas/tables/saml_application_sessions.sql index e3f97dd382ee..26c79f829645 100644 --- a/packages/schemas/tables/saml_application_sessions.sql +++ b/packages/schemas/tables/saml_application_sessions.sql @@ -5,7 +5,7 @@ create table saml_application_sessions ( references tenants (id) on update cascade on delete cascade, /** The globally unique identifier of the session. */ id varchar(32) not null, - application_id varchar(21) not null + application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** The identifier of the SAML SSO auth request ID, SAML request ID is pretty long. */ saml_request_id varchar(128) not null, diff --git a/packages/schemas/tables/scopes.sql b/packages/schemas/tables/scopes.sql index 954009d2c0bc..2c59b831b19b 100644 --- a/packages/schemas/tables/scopes.sql +++ b/packages/schemas/tables/scopes.sql @@ -3,8 +3,8 @@ create table scopes ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - resource_id varchar(21) not null + id varchar(36) not null, + resource_id varchar(36) not null references resources (id) on update cascade on delete cascade, name varchar(256) not null, description text, diff --git a/packages/schemas/tables/secret_enterprise_sso_connector_relations.sql b/packages/schemas/tables/secret_enterprise_sso_connector_relations.sql index 36e4c365e49b..525555d2f8c9 100644 --- a/packages/schemas/tables/secret_enterprise_sso_connector_relations.sql +++ b/packages/schemas/tables/secret_enterprise_sso_connector_relations.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ create table secret_enterprise_sso_connector_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - secret_id varchar(21) not null + secret_id ${id_format} not null references secrets (id) on update cascade on delete cascade, /** SSO connector ID foreign reference. Only present for secrets that store SSO connector tokens. Note: avoid directly cascading deletes here, need to delete the secrets first.*/ sso_connector_id varchar(128) not null diff --git a/packages/schemas/tables/secret_social_connector_relations.sql b/packages/schemas/tables/secret_social_connector_relations.sql index ce24137fd505..896787803da1 100644 --- a/packages/schemas/tables/secret_social_connector_relations.sql +++ b/packages/schemas/tables/secret_social_connector_relations.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 3 */ create table secret_social_connector_relations ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - secret_id varchar(21) not null + secret_id ${id_format} not null references secrets (id) on update cascade on delete cascade, /** Social connector ID foreign reference. Only present for secrets that store social connector tokens. Note: avoid directly cascading deletes here, need to delete the secrets first.*/ connector_id varchar(128) not null diff --git a/packages/schemas/tables/secrets.sql b/packages/schemas/tables/secrets.sql index b77749d50331..1cef096e18d6 100644 --- a/packages/schemas/tables/secrets.sql +++ b/packages/schemas/tables/secrets.sql @@ -1,9 +1,11 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table secrets ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null primary key, - user_id varchar(21) not null + id ${id_format} not null primary key, + user_id ${id_format} not null references users (id) on update cascade on delete cascade, type varchar(256) /* @use SecretType */ not null, /** Encrypted data encryption key (DEK) for the secret. */ diff --git a/packages/schemas/tables/sentinel_activities.sql b/packages/schemas/tables/sentinel_activities.sql index 0aebbf123f39..75cc5acd4ac5 100644 --- a/packages/schemas/tables/sentinel_activities.sql +++ b/packages/schemas/tables/sentinel_activities.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create type sentinel_action_result as enum ('Success', 'Failed'); create type sentinel_decision as enum ('Undecided', 'Allowed', 'Blocked', 'Challenge'); @@ -5,7 +7,7 @@ create type sentinel_decision as enum ('Undecided', 'Allowed', 'Blocked', 'Chall create table sentinel_activities ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, + id ${id_format} not null, /** The target that the action was performed on. */ target_type varchar(32) /* @use SentinelActivityTargetType */ not null, /** The target hashed identifier. */ diff --git a/packages/schemas/tables/service_logs.sql b/packages/schemas/tables/service_logs.sql index b9846aa0390c..9d7feeef95c3 100644 --- a/packages/schemas/tables/service_logs.sql +++ b/packages/schemas/tables/service_logs.sql @@ -1,5 +1,7 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table service_logs ( - id varchar(21) not null, + id ${id_format} not null, tenant_id varchar(21) not null, type varchar(64) not null, payload jsonb /* @use JsonObject */ not null default '{}'::jsonb, diff --git a/packages/schemas/tables/sso_connector_idp_initiated_auth_configs.sql b/packages/schemas/tables/sso_connector_idp_initiated_auth_configs.sql index 4ced81ae13df..378ecb69fddf 100644 --- a/packages/schemas/tables/sso_connector_idp_initiated_auth_configs.sql +++ b/packages/schemas/tables/sso_connector_idp_initiated_auth_configs.sql @@ -6,7 +6,7 @@ create table sso_connector_idp_initiated_auth_configs ( connector_id varchar(128) not null references sso_connectors (id) on update cascade on delete cascade, /** The default Logto application id. */ - default_application_id varchar(21) not null + default_application_id varchar(36) not null references applications (id) on update cascade on delete cascade, /** OIDC sign-in redirect URI. */ redirect_uri text, diff --git a/packages/schemas/tables/subject_tokens.sql b/packages/schemas/tables/subject_tokens.sql index d8879f7dae0e..eb10708ff853 100644 --- a/packages/schemas/tables/subject_tokens.sql +++ b/packages/schemas/tables/subject_tokens.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table subject_tokens ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, @@ -5,7 +7,7 @@ create table subject_tokens ( context jsonb /* @use JsonObject */ not null default '{}'::jsonb, expires_at timestamptz not null, consumed_at timestamptz, - user_id varchar(21) not null + user_id ${id_format} not null references users (id) on update cascade on delete cascade, created_at timestamptz not null default(now()), /* It is intented to not reference to user or application table, it can be userId or applicationId, for audit only */ diff --git a/packages/schemas/tables/user_sso_identities.sql b/packages/schemas/tables/user_sso_identities.sql index 9418bf84cae6..ed8234ba4a4d 100644 --- a/packages/schemas/tables/user_sso_identities.sql +++ b/packages/schemas/tables/user_sso_identities.sql @@ -1,10 +1,12 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table user_sso_identities ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - user_id varchar(12) not null references users (id) on update cascade on delete cascade, + id ${id_format} not null, + user_id ${id_format} not null references users (id) on update cascade on delete cascade, /** Unique provider identifier. Issuer of the OIDC connectors, entityId of the SAML providers */ issuer varchar(256) not null, /** Provider user identity id*/ diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index a9def1ede589..2550d4388952 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -1,3 +1,5 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 1 */ create type users_password_encryption_method as enum ('Argon2i', 'Argon2id', 'Argon2d', 'SHA1', 'SHA256', 'MD5', 'Bcrypt', 'Legacy'); @@ -5,7 +7,7 @@ create type users_password_encryption_method as enum ('Argon2i', 'Argon2id', 'Ar create table users ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(12) not null, + id ${id_format} not null, username varchar(128), primary_email varchar(128), primary_phone varchar(128), @@ -16,7 +18,7 @@ create table users ( avatar varchar(2048), /** Additional OpenID Connect standard claims that are not included in user's properties. */ profile jsonb /* @use UserProfile */ not null default '{}'::jsonb, - application_id varchar(21), + application_id varchar(36), identities jsonb /* @use Identities */ not null default '{}'::jsonb, custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb, logto_config jsonb /* @use JsonObject */ not null default '{}'::jsonb, diff --git a/packages/schemas/tables/users_roles.sql b/packages/schemas/tables/users_roles.sql index 2e7cbc75e5ec..d98f2c0a9230 100644 --- a/packages/schemas/tables/users_roles.sql +++ b/packages/schemas/tables/users_roles.sql @@ -1,12 +1,14 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + /* init_order = 2 */ create table users_roles ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - user_id varchar(21) not null + id ${id_format} not null, + user_id ${id_format} not null references users (id) on update cascade on delete cascade, - role_id varchar(21) not null + role_id ${id_format} not null references roles (id) on update cascade on delete cascade, primary key (id), constraint users_roles__user_id_role_id diff --git a/packages/schemas/tables/verification_records.sql b/packages/schemas/tables/verification_records.sql index 1e9bc3101bcb..0689c5fcbbc6 100644 --- a/packages/schemas/tables/verification_records.sql +++ b/packages/schemas/tables/verification_records.sql @@ -1,8 +1,10 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table verification_records ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - user_id varchar(21) + id ${id_format} not null, + user_id ${id_format} references users (id) on update cascade on delete cascade, created_at timestamptz not null default(now()), expires_at timestamptz not null, diff --git a/packages/schemas/tables/verification_statuses.sql b/packages/schemas/tables/verification_statuses.sql index 4442988eb402..b2a4eec6539f 100644 --- a/packages/schemas/tables/verification_statuses.sql +++ b/packages/schemas/tables/verification_statuses.sql @@ -1,8 +1,10 @@ +/* Note: id_format columns are replaced at seed time with uuid or varchar(21) depending on the ID_FORMAT setting. */ + create table verification_statuses ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade, - id varchar(21) not null, - user_id varchar(21) not null + id ${id_format} not null, + user_id ${id_format} not null references users (id) on update cascade on delete cascade, created_at timestamptz not null default(now()), verified_identifier varchar(255), diff --git a/packages/shared/package.json b/packages/shared/package.json index ef13063b51f3..3f5124c1c5d3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -63,6 +63,7 @@ "chalk": "^5.3.0", "find-up": "^7.0.0", "libphonenumber-js": "^1.12.6", - "nanoid": "^5.0.9" + "nanoid": "^5.0.9", + "uuid": "^13.0.0" } } diff --git a/packages/shared/src/node/env/GlobalValues.ts b/packages/shared/src/node/env/GlobalValues.ts index 94788418050a..da572716338a 100644 --- a/packages/shared/src/node/env/GlobalValues.ts +++ b/packages/shared/src/node/env/GlobalValues.ts @@ -1,8 +1,23 @@ import { assertEnv, getEnv, getEnvAsStringArray, tryThat, yes } from '@silverhand/essentials'; +import { type IdFormat, isIdFormat } from '../../utils/id.js'; + import UrlSet from './UrlSet.js'; import { throwErrorWithDsnMessage } from './throw-errors.js'; +const resolveIdFormat = (): IdFormat | undefined => { + const value = getEnv('ID_FORMAT'); + if (!value) { + return undefined; + } + if (!isIdFormat(value)) { + throw new Error( + `Invalid ID_FORMAT environment variable: '${value}'. Must be 'nanoid' or 'uuid'.` + ); + } + return value; +}; + export default class GlobalValues { public readonly isProduction = getEnv('NODE_ENV') === 'production'; public readonly isIntegrationTest = yes(getEnv('INTEGRATION_TEST')); @@ -131,6 +146,13 @@ export default class GlobalValues { */ public readonly redisUrl = getEnv('REDIS_URL'); + /** + * ID format for all entity IDs on this instance. + * Can be 'nanoid' or 'uuid'. When unset, the value is read from the database at startup. + * Once the database is seeded with a format, this value is permanent and cannot be changed. + */ + public readonly idFormat = resolveIdFormat(); + public get dbUrl(): string { return this.databaseUrl; } diff --git a/packages/shared/src/utils/id.test.ts b/packages/shared/src/utils/id.test.ts index 788f826676e1..d1309027e714 100644 --- a/packages/shared/src/utils/id.test.ts +++ b/packages/shared/src/utils/id.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { generateStandardId, generateStandardSecret, generateStandardShortId } from './id.js'; +import { + IdFormat, + generateId, + generateStandardId, + generateStandardSecret, + generateStandardShortId, + generateUuidV7, +} from './id.js'; describe('standard id generator', () => { it('should match the input length', () => { @@ -29,3 +36,59 @@ describe('standard secret generator', () => { } }); }); + +describe('UUID v7 generator', () => { + it('should generate a valid UUID v7', () => { + const uuid = generateUuidV7(); + expect(uuid.length).toEqual(36); + // UUID v7 format: 8-4-7xxx-xxxx-12 with hyphens (version field is '7') + expect(uuid).toMatch(/^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i); + }); + + it('should generate unique UUID v7s', () => { + const uuid1 = generateUuidV7(); + const uuid2 = generateUuidV7(); + expect(uuid1).not.toEqual(uuid2); + }); + + it('should generate time-ordered UUIDs', () => { + const uuid1 = generateUuidV7(); + // Small delay to ensure different timestamp + const start = Date.now(); + while (Date.now() - start < 2) { + // Wait 2ms + } + const uuid2 = generateUuidV7(); + + // UUID v7 should be sortable - later UUIDs should be greater + expect(uuid2 > uuid1).toBe(true); + }); +}); + +describe('generateId', () => { + it('should generate UUID v7 when format is uuid', () => { + const id = generateId(IdFormat.Uuid); + expect(id.length).toEqual(36); + // UUID v7 format: 8-4-7xxx-xxxx-12 with hyphens (version field is '7') + expect(id).toMatch(/^[\da-f]{8}-[\da-f]{4}-7[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i); + }); + + it('should generate nanoid when format is nanoid with default size', () => { + const id = generateId(IdFormat.Nanoid); + expect(id.length).toEqual(21); + // Nanoid uses lowercase alphanumeric characters + expect(id).toMatch(/^[\da-z]{21}$/); + }); + + it('should generate nanoid with custom size', () => { + const id = generateId(IdFormat.Nanoid, 12); + expect(id.length).toEqual(12); + expect(id).toMatch(/^[\da-z]{12}$/); + }); + + it('should generate nanoid with size 21 when size is specified', () => { + const id = generateId(IdFormat.Nanoid, 21); + expect(id.length).toEqual(21); + expect(id).toMatch(/^[\da-z]{21}$/); + }); +}); diff --git a/packages/shared/src/utils/id.ts b/packages/shared/src/utils/id.ts index 68652fe05eb6..e0035eb1a3ea 100644 --- a/packages/shared/src/utils/id.ts +++ b/packages/shared/src/utils/id.ts @@ -1,4 +1,5 @@ import { customAlphabet } from 'nanoid'; +import { v5 as uuidv5, v7 as uuidv7 } from 'uuid'; const lowercaseAlphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; const alphabet = `${lowercaseAlphabet}ABCDEFGHIJKLMNOPQRSTUVWXYZ` as const; @@ -21,14 +22,29 @@ const buildIdGenerator: BuildIdGenerator = (size: number, includingUppercase = t customAlphabet(includingUppercase ? alphabet : lowercaseAlphabet, size); /** - * Generate a standard id with 21 characters, including lowercase letters and numbers. + * ID format types supported by Logto. + * - Nanoid: Compact, URL-safe IDs (12-21 characters) + * - Uuid: UUID v7 (time-ordered, 36 characters) + */ +export enum IdFormat { + Nanoid = 'nanoid', + Uuid = 'uuid', +} + +/** + * Generate a format-aware standard ID. + * For 'nanoid' format, generates a lowercase alphanumeric string of the given size (default 21). + * For 'uuid' format, generates a UUID v7 string. * - * @see {@link lowercaseAlphabet} + * @param size - Only affects nanoid format. Has no effect in uuid mode since UUIDs have a fixed + * 36-character length. */ -export const generateStandardId = buildIdGenerator(21, false); +export const generateStandardId = (size?: number): string => generateId(undefined, size); /** * Generate a standard short id with 12 characters, including lowercase letters and numbers. + * This is NOT format-aware — it always generates a nanoid regardless of ID_FORMAT. + * Used for non-entity IDs like connector IDs, UI keys, etc. * * @see {@link lowercaseAlphabet} */ @@ -41,3 +57,73 @@ export const generateStandardShortId = buildIdGenerator(12, false); * @see {@link alphabet} */ export const generateStandardSecret = buildIdGenerator(32); + +/** + * Generate a UUID v7 string (time-ordered). + * UUID v7 includes a timestamp in the first 48 bits, making it sortable by creation time. + * This provides better database index performance compared to UUID v4. + * + * @returns A UUID v7 string (36 characters with hyphens, e.g., "018e8c3a-9d2e-7890-a123-456789abcdef") + */ +export const generateUuidV7 = (): string => uuidv7(); + +/** + * Fixed namespace UUID for deterministic seed ID generation via UUID v5. + * This must never change — all existing UUID-format databases depend on it. + */ +const logtoSeedNamespace = 'f4f4f4f4-f4f4-4f4f-8f4f-f4f4f4f4f4f4'; + +/** + * Convert a hardcoded seed ID name to the appropriate format. + * For 'nanoid' format, returns the name as-is (human-readable string). + * For 'uuid' format, returns a deterministic UUID v5 derived from the name, + * which is a valid UUID and can be stored in native PostgreSQL `uuid` columns. + * + * @param name - The human-readable seed ID (e.g., 'admin-console', 'admin-role') + * @returns The format-appropriate ID string + */ +export const buildSeedId = (name: string): string => { + if (getIdFormat() === IdFormat.Nanoid) { + return name; + } + return uuidv5(name, logtoSeedNamespace); +}; + +/** + * Read the current ID format from the `ID_FORMAT` environment variable. + * Defaults to 'nanoid' if not set. + */ +const idFormatValues: ReadonlySet = new Set(Object.values(IdFormat)); + +export const isIdFormat = (value: string): value is IdFormat => idFormatValues.has(value); + +export const getIdFormat = (): IdFormat => { + const format = process.env.ID_FORMAT ?? IdFormat.Nanoid; + if (!isIdFormat(format)) { + throw new Error( + `Invalid ID_FORMAT environment variable: '${format}'. Must be 'nanoid' or 'uuid'.` + ); + } + return format; +}; + +/** + * Generate a format-aware ID. + * When no format is specified, reads from the ID_FORMAT env variable (defaults to 'nanoid'). + * Use this for entity columns that support uuid type (users, roles, organizations, etc.). + * + * @param format - The ID format to use. Defaults to getIdFormat() if omitted. + * @param size - Only affects nanoid format (defaults to 21). Has no effect in uuid mode since + * UUIDs have a fixed 36-character length. + * @returns A generated ID string + */ +export const generateId = (format?: IdFormat, size?: number): string => { + const resolvedFormat = format ?? getIdFormat(); + if (resolvedFormat === IdFormat.Uuid) { + return generateUuidV7(); + } + + // Default to standard size (21) if not specified + const idSize = size ?? 21; + return buildIdGenerator(idSize, false)(); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54c9910effa5..d834477ea5e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4636,6 +4636,9 @@ importers: nanoid: specifier: ^5.0.9 version: 5.0.9 + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@jest/globals': specifier: ^29.7.0 @@ -11642,6 +11645,7 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -14832,6 +14836,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -27724,6 +27732,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@13.0.0: {} + uuid@8.3.2: {} uuid@9.0.1: {}