diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts index 3e723151c..f153d0f6c 100644 --- a/packages/cli/src/commands/database/config.ts +++ b/packages/cli/src/commands/database/config.ts @@ -1,5 +1,10 @@ import type { LogtoConfigKey } from '@logto/schemas'; -import { LogtoOidcConfigKey, logtoConfigGuards, logtoConfigKeys } from '@logto/schemas'; +import { + defaultTenantId, + LogtoOidcConfigKey, + logtoConfigGuards, + logtoConfigKeys, +} from '@logto/schemas'; import { deduplicate, noop } from '@silverhand/essentials'; import chalk from 'chalk'; import type { CommandModule } from 'yargs'; @@ -45,7 +50,7 @@ const validateRotateKey: ValidateRotateKeyFunction = (key) => { } }; -const getConfig: CommandModule = { +const getConfig: CommandModule = { command: 'get [keys...]', describe: 'Get config value(s) of the given key(s) in Logto database', builder: (yargs) => @@ -60,13 +65,18 @@ const getConfig: CommandModule = { type: 'string', array: true, default: [], + }) + .option('tenantId', { + describe: 'The tenant to operate', + type: 'string', + default: defaultTenantId, }), - handler: async ({ key, keys }) => { + handler: async ({ key, keys, tenantId }) => { const queryKeys = deduplicate([key, ...keys]); validateKeys(queryKeys); const pool = await createPoolFromConfig(); - const { rows } = await getRowsByKeys(pool, queryKeys); + const { rows } = await getRowsByKeys(pool, tenantId, queryKeys); await pool.end(); console.log( @@ -85,7 +95,7 @@ const getConfig: CommandModule = { }, }; -const setConfig: CommandModule = { +const setConfig: CommandModule = { command: 'set ', describe: 'Set config value of the given key in Logto database', builder: (yargs) => @@ -99,35 +109,46 @@ const setConfig: CommandModule = { describe: 'The value to set, should be a valid JSON string', type: 'string', demandOption: true, + }) + .option('tenantId', { + describe: 'The tenant to operate', + type: 'string', + default: defaultTenantId, }), - handler: async ({ key, value }) => { + handler: async ({ key, value, tenantId }) => { validateKeys(key); const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); const pool = await createPoolFromConfig(); - await updateValueByKey(pool, key, guarded); + await updateValueByKey(pool, tenantId, key, guarded); await pool.end(); log.info(`Update ${chalk.green(key)} succeeded`); }, }; -const rotateConfig: CommandModule = { +const rotateConfig: CommandModule = { command: 'rotate ', describe: 'Generate a new private or secret key for the given config key and prepend to the key array', builder: (yargs) => - yargs.positional('key', { - describe: `The key to rotate, one of ${chalk.green(validRotateKeys.join(', '))}`, - type: 'string', - demandOption: true, - }), - handler: async ({ key }) => { + yargs + .positional('key', { + describe: `The key to rotate, one of ${chalk.green(validRotateKeys.join(', '))}`, + type: 'string', + demandOption: true, + }) + .option('tenantId', { + describe: 'The tenant to operate', + type: 'string', + default: defaultTenantId, + }), + handler: async ({ key, tenantId }) => { validateRotateKey(key); const pool = await createPoolFromConfig(); - const { rows } = await getRowsByKeys(pool, [key]); + const { rows } = await getRowsByKeys(pool, tenantId, [key]); if (!rows[0]) { log.warn('No key found, create a new one'); @@ -147,14 +168,14 @@ const rotateConfig: CommandModule = { } }; const rotated = await getValue(); - await updateValueByKey(pool, key, rotated); + await updateValueByKey(pool, tenantId, key, rotated); await pool.end(); log.info(`Rotate ${chalk.green(key)} succeeded, now it has ${rotated.length} keys`); }, }; -const trimConfig: CommandModule = { +const trimConfig: CommandModule = { command: 'trim [length]', describe: 'Remove the last [length] number of private or secret keys for the given config key', builder: (yargs) => @@ -169,8 +190,13 @@ const trimConfig: CommandModule = { type: 'number', default: 1, demandOption: true, + }) + .option('tenantId', { + describe: 'The tenant to operate', + type: 'string', + default: defaultTenantId, }), - handler: async ({ key, length }) => { + handler: async ({ key, length, tenantId }) => { validateRotateKey(key); if (length < 1) { @@ -178,7 +204,7 @@ const trimConfig: CommandModule = { } const pool = await createPoolFromConfig(); - const { rows } = await getRowsByKeys(pool, [key]); + const { rows } = await getRowsByKeys(pool, tenantId, [key]); if (!rows[0]) { log.warn('No key found, create a new one'); @@ -195,7 +221,7 @@ const trimConfig: CommandModule = { return value.slice(0, -length); }; const trimmed = await getValue(); - await updateValueByKey(pool, key, trimmed); + await updateValueByKey(pool, tenantId, key, trimmed); await pool.end(); log.info(`Trim ${chalk.green(key)} succeeded, now it has ${trimmed.length} keys`); diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts index 4aa6d9db5..75c5183b3 100644 --- a/packages/cli/src/commands/database/seed/index.ts +++ b/packages/cli/src/commands/database/seed/index.ts @@ -1,68 +1,14 @@ -import { logtoConfigGuards, LogtoOidcConfigKey } from '@logto/schemas'; import chalk from 'chalk'; -import type { DatabasePool, DatabaseTransactionConnection } from 'slonik'; +import type { DatabasePool } from 'slonik'; import type { CommandModule } from 'yargs'; -import { z } from 'zod'; import { createPoolAndDatabaseIfNeeded } from '../../../database.js'; -import { - getRowsByKeys, - doesConfigsTableExist, - updateValueByKey, -} from '../../../queries/logto-config.js'; +import { doesConfigsTableExist } from '../../../queries/logto-config.js'; import { log, oraPromise } from '../../../utilities.js'; import { getLatestAlterationTimestamp } from '../alteration/index.js'; import { getAlterationDirectory } from '../alteration/utils.js'; -import { oidcConfigReaders } from './oidc-config.js'; import { createTables, seedTables } from './tables.js'; -const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { - const configGuard = z.object({ - key: z.nativeEnum(LogtoOidcConfigKey), - value: z.unknown(), - }); - const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey)); - // Filter out valid keys that hold a valid value - const result = await Promise.all( - rows.map>(async (row) => { - try { - const { key, value } = await configGuard.parseAsync(row); - await logtoConfigGuards[key].parseAsync(value); - - return key; - } catch {} - }) - ); - const existingKeys = new Set(result.filter(Boolean)); - - const validOptions = Object.values(LogtoOidcConfigKey).filter((key) => { - const included = existingKeys.has(key); - - if (included) { - log.info(`Key ${chalk.green(key)} exists, skipping`); - } - - return !included; - }); - - // The awaits in loop is intended since we'd like to log info in sequence - /* eslint-disable no-await-in-loop */ - for (const key of validOptions) { - const { value, fromEnv } = await oidcConfigReaders[key](); - - if (fromEnv) { - log.info(`Read config ${chalk.green(key)} from env`); - } else { - log.info(`Generated config ${chalk.green(key)}`); - } - - await updateValueByKey(pool, key, value); - } - /* eslint-enable no-await-in-loop */ - - log.succeed('Seed OIDC config'); -}; - const seedChoices = Object.freeze(['all', 'oidc'] as const); type SeedChoice = typeof seedChoices[number]; @@ -89,8 +35,6 @@ export const seedByPool = async (pool: DatabasePool, type: SeedChoice) => { prefixText: chalk.blue('[info]'), }); } - - await seedOidcConfigs(connection); }); }; diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index a5a5cfc35..622a7d368 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -1,13 +1,66 @@ import { readFile } from 'fs/promises'; import type { LogtoOidcConfigType } from '@logto/schemas'; -import { LogtoOidcConfigKey } from '@logto/schemas'; +import { LogtoOidcConfigKey, logtoConfigGuards } from '@logto/schemas'; import { getEnvAsStringArray } from '@silverhand/essentials'; +import chalk from 'chalk'; +import type { DatabaseTransactionConnection } from 'slonik'; +import { z } from 'zod'; +import { getRowsByKeys, updateValueByKey } from '../../../queries/logto-config.js'; +import { log } from '../../../utilities.js'; import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js'; const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); +export const seedOidcConfigs = async (pool: DatabaseTransactionConnection, tenantId: string) => { + const tenantPrefix = `[${tenantId}]`; + const configGuard = z.object({ + key: z.nativeEnum(LogtoOidcConfigKey), + value: z.unknown(), + }); + const { rows } = await getRowsByKeys(pool, tenantId, Object.values(LogtoOidcConfigKey)); + // Filter out valid keys that hold a valid value + const result = await Promise.all( + rows.map>(async (row) => { + try { + const { key, value } = await configGuard.parseAsync(row); + await logtoConfigGuards[key].parseAsync(value); + + return key; + } catch {} + }) + ); + const existingKeys = new Set(result.filter(Boolean)); + + const validOptions = Object.values(LogtoOidcConfigKey).filter((key) => { + const included = existingKeys.has(key); + + if (included) { + log.info(tenantPrefix, `Key ${chalk.green(key)} exists, skipping`); + } + + return !included; + }); + + // The awaits in loop is intended since we'd like to log info in sequence + /* eslint-disable no-await-in-loop */ + for (const key of validOptions) { + const { value, fromEnv } = await oidcConfigReaders[key](); + + if (fromEnv) { + log.info(tenantPrefix, `Read config ${chalk.green(key)} from env`); + } else { + log.info(tenantPrefix, `Generated config ${chalk.green(key)}`); + } + + await updateValueByKey(pool, tenantId, key, value); + } + /* eslint-enable no-await-in-loop */ + + log.succeed(tenantPrefix, 'Seed OIDC config'); +}; + /** * Each config reader will do the following things in order: * 1. Try to read value from env (mimic the behavior from the original core) diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index 8bb9839ea..285113faf 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -3,14 +3,13 @@ import path from 'path'; import { generateStandardId } from '@logto/core-kit'; import { - managementResource, defaultSignInExperience, createDefaultAdminConsoleConfig, createDemoAppApplication, - defaultRole, - managementResourceScope, - defaultRoleScopeRelation, defaultTenantId, + adminTenantId, + defaultManagementApi, + createManagementApiInAdminTenant, } from '@logto/schemas'; import { Hooks, Tenants } from '@logto/schemas/models'; import type { DatabaseTransactionConnection } from 'slonik'; @@ -21,7 +20,8 @@ import { insertInto } from '../../../database.js'; import { getDatabaseName } from '../../../queries/database.js'; import { updateDatabaseTimestamp } from '../../../queries/system.js'; import { getPathInModule } from '../../../utilities.js'; -import { createTenant } from './tenant.js'; +import { seedOidcConfigs } from './oidc-config.js'; +import { createTenant, seedAdminData } from './tenant.js'; const getExplicitOrder = (query: string) => { const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1]; @@ -114,14 +114,17 @@ export const seedTables = async ( latestTimestamp: number ) => { await createTenant(connection, defaultTenantId); + await seedOidcConfigs(connection, defaultTenantId); + await seedAdminData(connection, defaultManagementApi); + + await createTenant(connection, adminTenantId); + await seedOidcConfigs(connection, adminTenantId); + await seedAdminData(connection, createManagementApiInAdminTenant(defaultTenantId)); + await Promise.all([ - connection.query(insertInto(managementResource, 'resources')), - connection.query(insertInto(managementResourceScope, 'scopes')), connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')), connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')), - connection.query(insertInto(defaultRole, 'roles')), - connection.query(insertInto(defaultRoleScopeRelation, 'roles_scopes')), updateDatabaseTimestamp(connection, latestTimestamp), ]); }; diff --git a/packages/cli/src/commands/database/seed/tenant.ts b/packages/cli/src/commands/database/seed/tenant.ts index 5179e213e..768c8f3ef 100644 --- a/packages/cli/src/commands/database/seed/tenant.ts +++ b/packages/cli/src/commands/database/seed/tenant.ts @@ -1,23 +1,49 @@ import { generateStandardId } from '@logto/core-kit'; -import type { TenantModel } from '@logto/schemas'; -import type { DatabaseTransactionConnection } from 'slonik'; +import { CreateRolesScope } from '@logto/schemas'; +import type { TenantModel, AdminData } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; +import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; import { raw } from 'slonik-sql-tag-raw'; import { insertInto } from '../../../database.js'; import { getDatabaseName } from '../../../queries/database.js'; -export const createTenant = async (connection: DatabaseTransactionConnection, tenantId: string) => { - const database = await getDatabaseName(connection, true); +export const createTenant = async (pool: CommonQueryMethods, tenantId: string) => { + const database = await getDatabaseName(pool, true); const parentRole = `logto_tenant_${database}`; const role = `logto_tenant_${database}_${tenantId}`; const password = generateStandardId(32); const tenantModel: TenantModel = { id: tenantId, dbUser: role, dbUserPassword: password }; - await connection.query(insertInto(tenantModel, 'tenants')); - await connection.query(sql` + await pool.query(insertInto(tenantModel, 'tenants')); + await pool.query(sql` create role ${sql.identifier([role])} with inherit login password '${raw(password)}' in role ${sql.identifier([parentRole])}; `); }; + +export const seedAdminData = async (pool: CommonQueryMethods, data: AdminData) => { + const { resource, scope, role } = data; + + assert( + resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId, + new Error('All data should have the same tenant ID') + ); + + await pool.query(insertInto(resource, 'resources')); + await pool.query(insertInto(scope, 'scopes')); + await pool.query(insertInto(role, 'roles')); + await pool.query( + insertInto( + { + id: generateStandardId(), + roleId: role.id, + scopeId: scope.id, + tenantId: resource.tenantId, + } satisfies CreateRolesScope, + 'roles_scopes' + ) + ); +}; diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts index 61dc9978d..401e25aeb 100644 --- a/packages/cli/src/queries/logto-config.ts +++ b/packages/cli/src/queries/logto-config.ts @@ -16,21 +16,27 @@ export const doesConfigsTableExist = async (pool: CommonQueryMethods) => { return Boolean(rows[0]?.regclass); }; -export const getRowsByKeys = async (pool: CommonQueryMethods, keys: LogtoConfigKey[]) => +export const getRowsByKeys = async ( + pool: CommonQueryMethods, + tenantId: string, + keys: LogtoConfigKey[] +) => pool.query(sql` select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} - where ${fields.key} in (${sql.join(keys, sql`,`)}) + where ${fields.tenantId} = ${tenantId} + and ${fields.key} in (${sql.join(keys, sql`,`)}) `); export const updateValueByKey = async ( pool: CommonQueryMethods, + tenantId: string, key: T, value: z.infer ) => pool.query( sql` - insert into ${table} (${fields.key}, ${fields.value}) - values (${key}, ${sql.jsonb(value)}) + insert into ${table} (${fields.tenantId}, ${fields.key}, ${fields.value}) + values (${tenantId}, ${key}, ${sql.jsonb(value)}) on conflict (${fields.tenantId}, ${fields.key}) do update set ${fields.value}=excluded.${fields.value} ` diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index e465ec5b2..6c34ab5cd 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -1,10 +1,7 @@ import { UserScope } from '@logto/core-kit'; import { LogtoProvider } from '@logto/react'; -import { - adminConsoleApplicationId, - managementResource, - managementResourceScope, -} from '@logto/schemas'; +import { adminConsoleApplicationId } from '@logto/schemas'; +import { useContext } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { SWRConfig } from 'swr'; @@ -14,7 +11,9 @@ import './scss/overlayscrollbars.scss'; // eslint-disable-next-line import/no-unassigned-import import '@fontsource/roboto-mono'; +import AppLoading from '@/components/AppLoading'; import Toast from '@/components/Toast'; +import { managementApi } from '@/consts/management-api'; import AppBoundary from '@/containers/AppBoundary'; import AppLayout from '@/containers/AppLayout'; import ErrorBoundary from '@/containers/ErrorBoundary'; @@ -48,6 +47,7 @@ import { UserDetailsTabs, } from './consts/page-tabs'; import AppContent from './containers/AppContent'; +import AppEndpointProvider, { AppEndpointContext } from './containers/AppEndpointProvider'; import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions'; import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings'; import CloudPreview from './pages/CloudPreview'; @@ -63,6 +63,11 @@ void initI18n(); const Main = () => { const swrOptions = useSwrOptions(); + const { endpoint } = useContext(AppEndpointContext); + + if (!endpoint) { + return ; + } return ( @@ -154,16 +159,18 @@ const Main = () => { const App = () => ( - -
- + + +
+ + ); diff --git a/packages/console/src/consts/index.ts b/packages/console/src/consts/index.ts index adc07ac78..01de962d4 100644 --- a/packages/console/src/consts/index.ts +++ b/packages/console/src/consts/index.ts @@ -1,6 +1,7 @@ export * from './applications'; export * from './connectors'; export * from './logs'; +export * from './management-api'; export const themeStorageKey = 'logto:admin_console:theme'; export const requestTimeout = 20_000; diff --git a/packages/console/src/consts/management-api.ts b/packages/console/src/consts/management-api.ts new file mode 100644 index 000000000..2313bd289 --- /dev/null +++ b/packages/console/src/consts/management-api.ts @@ -0,0 +1,10 @@ +import { + defaultTenantId, + getManagementApiResourceIndicator, + managementApiScopeAll, +} from '@logto/schemas'; + +export const managementApi = Object.freeze({ + indicator: getManagementApiResourceIndicator(defaultTenantId), + scopeAll: managementApiScopeAll, +}); diff --git a/packages/console/src/containers/AppEndpointProvider/index.tsx b/packages/console/src/containers/AppEndpointProvider/index.tsx new file mode 100644 index 000000000..997aab98e --- /dev/null +++ b/packages/console/src/containers/AppEndpointProvider/index.tsx @@ -0,0 +1,31 @@ +import ky from 'ky'; +import type { ReactNode } from 'react'; +import { useMemo, useEffect, createContext, useState } from 'react'; + +type Props = { + children: ReactNode; +}; + +export const AppEndpointContext = createContext<{ endpoint?: URL }>({}); + +const AppEndpointProvider = ({ children }: Props) => { + const [endpoint, setEndpoint] = useState(); + const memorizedContext = useMemo(() => ({ endpoint }), [endpoint]); + + useEffect(() => { + const getEndpoint = async () => { + const { app } = await ky + .get(new URL('api/.well-known/endpoints', window.location.origin)) + .json<{ app: string }>(); + setEndpoint(new URL(app)); + }; + + void getEndpoint(); + }, []); + + return ( + {children} + ); +}; + +export default AppEndpointProvider; diff --git a/packages/console/src/hooks/use-api.ts b/packages/console/src/hooks/use-api.ts index ad33676c4..6624c6890 100644 --- a/packages/console/src/hooks/use-api.ts +++ b/packages/console/src/hooks/use-api.ts @@ -1,12 +1,12 @@ import { useLogto } from '@logto/react'; import type { RequestErrorBody } from '@logto/schemas'; -import { managementResource } from '@logto/schemas'; import ky from 'ky'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import { requestTimeout } from '@/consts'; +import { managementApi, requestTimeout } from '@/consts'; +import { AppEndpointContext } from '@/containers/AppEndpointProvider'; export class RequestError extends Error { status: number; @@ -24,6 +24,7 @@ type Props = { }; const useApi = ({ hideErrorToast }: Props = {}) => { + const { endpoint } = useContext(AppEndpointContext); const { isAuthenticated, getAccessToken } = useLogto(); const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -44,7 +45,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => { const api = useMemo( () => ky.create({ - prefixUrl: window.location.origin, + prefixUrl: endpoint, timeout: requestTimeout, hooks: { beforeError: hideErrorToast @@ -59,7 +60,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => { beforeRequest: [ async (request) => { if (isAuthenticated) { - const accessToken = await getAccessToken(managementResource.indicator); + const accessToken = await getAccessToken(managementApi.indicator); request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`); request.headers.set('Accept-Language', i18n.language); } @@ -67,7 +68,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => { ], }, }), - [hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language] + [endpoint, hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language] ); return api; diff --git a/packages/console/src/pages/ApiResourceDetails/index.tsx b/packages/console/src/pages/ApiResourceDetails/index.tsx index d0f5493e1..9555a4961 100644 --- a/packages/console/src/pages/ApiResourceDetails/index.tsx +++ b/packages/console/src/pages/ApiResourceDetails/index.tsx @@ -1,5 +1,5 @@ import type { Resource } from '@logto/schemas'; -import { AppearanceMode, managementResource } from '@logto/schemas'; +import { defaultManagementApi, AppearanceMode } from '@logto/schemas'; import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { toast } from 'react-hot-toast'; @@ -39,7 +39,7 @@ const ApiResourceDetails = () => { const Icon = theme === AppearanceMode.LightMode ? ApiResource : ApiResourceDark; const isOnPermissionPage = pathname.endsWith(ApiResourceDetailsTabs.Permissions); - const isLogtoManagementApiResource = data?.id === managementResource.id; + const isLogtoManagementApiResource = data?.indicator === defaultManagementApi.resource.indicator; const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false); diff --git a/packages/core/package.json b/packages/core/package.json index 7db6f7a04..b088f15da 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "test:report": "codecov -F core" }, "dependencies": { + "@koa/cors": "^4.0.0", "@logto/cli": "workspace:*", "@logto/connector-kit": "workspace:*", "@logto/core-kit": "workspace:*", @@ -84,6 +85,7 @@ "@types/koa-logger": "^3.1.1", "@types/koa-mount": "^4.0.0", "@types/koa-send": "^4.1.3", + "@types/koa__cors": "^3.3.0", "@types/node": "^18.11.18", "@types/oidc-provider": "^8.0.0", "@types/semver": "^7.3.12", diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 6af14b6d8..99005dbf8 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -6,7 +6,8 @@ import chalk from 'chalk'; import type Koa from 'koa'; import { EnvSet } from '#src/env-set/index.js'; -import { defaultTenant, tenantPool } from '#src/tenants/index.js'; +import { tenantPool } from '#src/tenants/index.js'; +import { getTenantId } from '#src/utils/tenant.js'; const logListening = (type: 'core' | 'admin' = 'core') => { const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet; @@ -16,24 +17,9 @@ const logListening = (type: 'core' | 'admin' = 'core') => { } }; -const getTenantId = () => { - const { isDomainBasedMultiTenancy, isProduction, isIntegrationTest, developmentTenantId } = - EnvSet.values; - - if (!isDomainBasedMultiTenancy) { - if ((!isProduction || isIntegrationTest) && developmentTenantId) { - return developmentTenantId; - } - - return defaultTenant; - } - - throw new Error('Not implemented'); -}; - export default async function initApp(app: Koa): Promise { app.use(async (ctx, next) => { - const tenantId = getTenantId(); + const tenantId = getTenantId(ctx.URL); if (!tenantId) { ctx.status = 404; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index b95341d92..3092bf01d 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -4,6 +4,7 @@ import type { QueryClient } from '@withtyped/server'; import type { DatabasePool } from 'slonik'; import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js'; +import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { appendPath } from '#src/utils/url.js'; import GlobalValues from './GlobalValues.js'; @@ -82,7 +83,7 @@ export class EnvSet { this.#pool = pool; this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest); - const { getOidcConfigs } = createLogtoConfigLibrary(pool); + const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool)); const oidcConfigs = await getOidcConfigs(); this.#oidc = await loadOidcValues( diff --git a/packages/core/src/libraries/application.ts b/packages/core/src/libraries/application.ts index eaa26e107..8e7ab033f 100644 --- a/packages/core/src/libraries/application.ts +++ b/packages/core/src/libraries/application.ts @@ -8,10 +8,10 @@ export const createApplicationLibrary = (queries: Queries) => { const { applicationsRoles: { findApplicationsRolesByApplicationId }, rolesScopes: { findRolesScopesByRoleIds }, - scopes: { findScopesByIdsAndResourceId }, + scopes: { findScopesByIdsAndResourceIndicator }, } = queries; - const findApplicationScopesForResourceId = async ( + const findApplicationScopesForResourceIndicator = async ( applicationId: string, resourceId: string ): Promise => { @@ -19,7 +19,7 @@ export const createApplicationLibrary = (queries: Queries) => { const rolesScopes = await findRolesScopesByRoleIds( applicationsRoles.map(({ roleId }) => roleId) ); - const scopes = await findScopesByIdsAndResourceId( + const scopes = await findScopesByIdsAndResourceIndicator( rolesScopes.map(({ scopeId }) => scopeId), resourceId ); @@ -28,6 +28,6 @@ export const createApplicationLibrary = (queries: Queries) => { }; return { - findApplicationScopesForResourceId, + findApplicationScopesForResourceIndicator, }; }; diff --git a/packages/core/src/libraries/logto-config.ts b/packages/core/src/libraries/logto-config.ts index cd56ccfa7..cb8ea2b26 100644 --- a/packages/core/src/libraries/logto-config.ts +++ b/packages/core/src/libraries/logto-config.ts @@ -1,14 +1,14 @@ -import { getRowsByKeys } from '@logto/cli/lib/queries/logto-config.js'; import type { LogtoOidcConfigType } from '@logto/schemas'; import { logtoOidcConfigGuard, LogtoOidcConfigKey } from '@logto/schemas'; import chalk from 'chalk'; -import type { CommonQueryMethods } from 'slonik'; import { z, ZodError } from 'zod'; -export const createLogtoConfigLibrary = (pool: CommonQueryMethods) => { +import type Queries from '#src/tenants/Queries.js'; + +export const createLogtoConfigLibrary = ({ getRowsByKeys }: Queries['logtoConfigs']) => { const getOidcConfigs = async (): Promise => { try { - const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey)); + const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey)); return z .object(logtoOidcConfigGuard) diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 74c3150a3..43d035b7d 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -1,7 +1,7 @@ import { builtInLanguages } from '@logto/phrases-ui'; import type { Branding, LanguageInfo, SignInExperience } from '@logto/schemas'; import { - defaultTenantId, + adminTenantId, SignInMode, ConnectorType, BrandingStyle, @@ -72,26 +72,27 @@ export const createSignInExperienceLibrary = ( const getSignInExperienceForApplication = async ( applicationId?: string ): Promise => { - const signInExperience = await findDefaultSignInExperience(); - // Hard code Admin Console sign-in methods settings. if (applicationId === adminConsoleApplicationId) { return { - // If we need to hard code, it implies Logto is running in the single-tenant mode; - // Thus we can hard code Tenant ID as well. - tenantId: defaultTenantId, ...adminConsoleSignInExperience, + tenantId: adminTenantId, branding: { ...adminConsoleSignInExperience.branding, slogan: i18next.t('admin_console.welcome.title'), }, - termsOfUseUrl: signInExperience.termsOfUseUrl, - languageInfo: signInExperience.languageInfo, + termsOfUseUrl: null, + languageInfo: { + autoDetect: true, + fallbackLanguage: 'en', + }, signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register, socialSignInConnectorTargets: [], }; } + const signInExperience = await findDefaultSignInExperience(); + // Insert Demo App Notification if (applicationId === demoAppApplicationId) { const { socialSignInConnectorTargets } = signInExperience; diff --git a/packages/core/src/libraries/user.test.ts b/packages/core/src/libraries/user.test.ts index cc6d42f75..475fe3610 100644 --- a/packages/core/src/libraries/user.test.ts +++ b/packages/core/src/libraries/user.test.ts @@ -1,5 +1,4 @@ import { UsersPasswordEncryptionMethod } from '@logto/schemas'; -import { createMockPool } from 'slonik'; import { mockResource, mockRole, mockScope } from '#src/__mocks__/index.js'; import { mockUser } from '#src/__mocks__/user.js'; @@ -7,17 +6,13 @@ import { MockQueries } from '#src/test-utils/tenant.js'; const { jest } = import.meta; -const pool = createMockPool({ - query: jest.fn(), -}); - const { encryptUserPassword, createUserLibrary } = await import('./user.js'); const hasUserWithId = jest.fn(); const queries = new MockQueries({ users: { hasUserWithId }, roles: { findRolesByRoleIds: async () => [mockRole] }, - scopes: { findScopesByIdsAndResourceId: async () => [mockScope] }, + scopes: { findScopesByIdsAndResourceIndicator: async () => [mockScope] }, usersRoles: { findUsersRolesByUserId: async () => [] }, rolesScopes: { findRolesScopesByRoleIds: async () => [] }, }); @@ -73,12 +68,12 @@ describe('encryptUserPassword()', () => { }); describe('findUserScopesForResourceId()', () => { - const { findUserScopesForResourceId } = createUserLibrary(queries); + const { findUserScopesForResourceIndicator } = createUserLibrary(queries); it('returns scopes that the user has access', async () => { - await expect(findUserScopesForResourceId(mockUser.id, mockResource.id)).resolves.toEqual([ - mockScope, - ]); + await expect( + findUserScopesForResourceIndicator(mockUser.id, mockResource.indicator) + ).resolves.toEqual([mockScope]); }); }); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 24f6fe5f9..e1db461cb 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -1,6 +1,6 @@ import { buildIdGenerator, generateStandardId } from '@logto/core-kit'; import type { User, CreateUser, Scope } from '@logto/schemas'; -import { Users, UsersPasswordEncryptionMethod, defaultRole } from '@logto/schemas'; +import { Users, UsersPasswordEncryptionMethod } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; import { deduplicate } from '@silverhand/essentials'; @@ -15,7 +15,6 @@ import assertThat from '#src/utils/assert-that.js'; import { encryptPassword } from '#src/utils/password.js'; const userId = buildIdGenerator(12); -const roleId = buildIdGenerator(21); export const encryptUserPassword = async ( password: string @@ -51,18 +50,11 @@ export type UserLibrary = ReturnType; export const createUserLibrary = (queries: Queries) => { const { pool, - roles: { findRolesByRoleNames, insertRoles, findRoleByRoleName, findRolesByRoleIds }, - users: { - hasUser, - hasUserWithEmail, - hasUserWithId, - hasUserWithPhone, - findUsersByIds, - findUserById, - }, + roles: { findRolesByRoleNames, findRoleByRoleName, findRolesByRoleIds }, + users: { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone, findUsersByIds }, usersRoles: { insertUsersRoles, findUsersRolesByRoleId, findUsersRolesByUserId }, rolesScopes: { findRolesScopesByRoleIds }, - scopes: { findScopesByIdsAndResourceId }, + scopes: { findScopesByIdsAndResourceIndicator }, } = queries; const generateUserId = async (retries = 500) => @@ -83,11 +75,8 @@ export const createUserLibrary = (queries: Queries) => { returning: true, }); - const insertUser = async (data: OmitAutoSetFields, isAdmin = false) => { - const roleNames = deduplicate([ - ...EnvSet.values.userDefaultRoleNames, - ...(isAdmin ? [defaultRole.name] : []), - ]); + const insertUser = async (data: OmitAutoSetFields, additionalRoleNames: string[]) => { + const roleNames = deduplicate([...EnvSet.values.userDefaultRoleNames, ...additionalRoleNames]); const roles = await findRolesByRoleNames(roleNames); assertThat(roles.length === roleNames.length, 'role.default_role_missing'); @@ -142,15 +131,16 @@ export const createUserLibrary = (queries: Queries) => { return findUsersByIds(usersRoles.map(({ userId }) => userId)); }; - const findUserScopesForResourceId = async ( + const findUserScopesForResourceIndicator = async ( userId: string, - resourceId: string + resourceIndicator: string ): Promise => { const usersRoles = await findUsersRolesByUserId(userId); const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId)); - const scopes = await findScopesByIdsAndResourceId( + + const scopes = await findScopesByIdsAndResourceIndicator( rolesScopes.map(({ scopeId }) => scopeId), - resourceId + resourceIndicator ); return scopes; @@ -168,7 +158,7 @@ export const createUserLibrary = (queries: Queries) => { insertUser, checkIdentifierCollision, findUsersByRoleName, - findUserScopesForResourceId, + findUserScopesForResourceIndicator, findUserRoles, }; }; diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index 6964f107e..4726f582c 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -1,4 +1,4 @@ -import { managementResourceScope, UserRole } from '@logto/schemas'; +import { defaultManagementApi } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -17,7 +17,7 @@ const { mockEsm } = createMockUtils(jest); const { jwtVerify } = mockEsm('jose', () => ({ jwtVerify: jest .fn() - .mockReturnValue({ payload: { sub: 'fooUser', scope: managementResourceScope.name } }), + .mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }), })); const koaAuth = await pickDefault(import('./koa-auth.js')); @@ -181,9 +181,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError( - forbiddenError - ); + await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError); }); it('expect to throw if jwt scope does not include management resource scope', async () => { @@ -198,9 +196,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError( - forbiddenError - ); + await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError); }); it('expect to throw unauthorized error if unknown error occurs', async () => { diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index 6feacba80..6457c6e93 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -1,6 +1,6 @@ import type { IncomingHttpHeaders } from 'http'; -import { managementResource, managementResourceScope } from '@logto/schemas'; +import { defaultManagementApi } from '@logto/schemas'; import type { Optional } from '@silverhand/essentials'; import { jwtVerify } from 'jose'; import type { MiddlewareType, Request } from 'koa'; @@ -54,7 +54,9 @@ export const verifyBearerTokenFromRequest = async ( const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; if ((!isProduction || isIntegrationTest) && userId) { - return { sub: userId, clientId: undefined, scopes: [managementResourceScope.name] }; + console.log(`Found dev user ID ${userId}, skip token validation.`); + + return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] }; } try { @@ -79,22 +81,19 @@ export const verifyBearerTokenFromRequest = async ( }; export default function koaAuth( - envSet: EnvSet, - forScope?: string + envSet: EnvSet ): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { const { sub, clientId, scopes } = await verifyBearerTokenFromRequest( envSet, ctx.request, - managementResource.indicator + defaultManagementApi.resource.indicator ); - if (forScope) { - assertThat( - scopes.includes(forScope), - new RequestError({ code: 'auth.forbidden', status: 403 }) - ); - } + assertThat( + scopes.includes(defaultManagementApi.scope.name), + new RequestError({ code: 'auth.forbidden', status: 403 }) + ); ctx.auth = { type: sub === clientId ? 'app' : 'user', diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 849b05fcb..1d723883c 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -34,8 +34,8 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li resources: { findResourceByIndicator }, users: { findUserById }, } = queries; - const { findUserScopesForResourceId } = libraries.users; - const { findApplicationScopesForResourceId } = libraries.applications; + const { findUserScopesForResourceIndicator } = libraries.users; + const { findApplicationScopesForResourceIndicator } = libraries.applications; const logoutSource = readFileSync('static/html/logout.html', 'utf8'); const cookieConfig = Object.freeze({ @@ -90,7 +90,7 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li throw new errors.InvalidTarget(); } - const { accessTokenTtl: accessTokenTTL, id } = resourceServer; + const { accessTokenTtl: accessTokenTTL } = resourceServer; const result = { accessTokenFormat: 'jwt', accessTokenTTL, @@ -103,7 +103,7 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li const userId = ctx.oidc.session?.accountId; if (userId) { - const scopes = await findUserScopesForResourceId(userId, id); + const scopes = await findUserScopesForResourceIndicator(userId, indicator); return { ...result, @@ -115,7 +115,7 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li // Machine to machine app if (clientId) { - const scopes = await findApplicationScopesForResourceId(clientId, id); + const scopes = await findApplicationScopesForResourceIndicator(clientId, indicator); return { ...result, diff --git a/packages/core/src/queries/logto-config.ts b/packages/core/src/queries/logto-config.ts index 110293a1b..471bf3c3d 100644 --- a/packages/core/src/queries/logto-config.ts +++ b/packages/core/src/queries/logto-config.ts @@ -1,4 +1,4 @@ -import type { AdminConsoleData } from '@logto/schemas'; +import type { AdminConsoleData, LogtoConfig, LogtoConfigKey } from '@logto/schemas'; import { AdminConsoleConfigKey, LogtoConfigs } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; @@ -21,5 +21,11 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => { returning ${fields.value} `); - return { getAdminConsoleConfig, updateAdminConsoleConfig }; + const getRowsByKeys = async (keys: LogtoConfigKey[]) => + pool.query(sql` + select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} + where ${fields.key} in (${sql.join(keys, sql`,`)}) + `); + + return { getAdminConsoleConfig, updateAdminConsoleConfig, getRowsByKeys }; }; diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 081559129..8b8174e5d 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -1,5 +1,5 @@ import type { CreateRole, Role } from '@logto/schemas'; -import { adminRoleId, SearchJointMode, Roles } from '@logto/schemas'; +import { defaultManagementApi, SearchJointMode, Roles } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalArraySql, conditionalSql, convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; @@ -34,7 +34,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { pool.one<{ count: number }>(sql` select count(*) from ${table} - where ${fields.id}<>${adminRoleId} + where ${fields.id}<>${defaultManagementApi.role.id} ${conditionalArraySql( excludeRoleIds, (value) => sql`and ${fields.id} not in (${sql.join(value, sql`, `)})` @@ -57,7 +57,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - where ${fields.id}<>${adminRoleId} + where ${fields.id}<>${defaultManagementApi.role.id} ${conditionalArraySql( excludeRoleIds, (value) => sql`and ${fields.id} not in (${sql.join(value, sql`, `)})` diff --git a/packages/core/src/queries/scope.ts b/packages/core/src/queries/scope.ts index dd2969b5d..489a5bdbf 100644 --- a/packages/core/src/queries/scope.ts +++ b/packages/core/src/queries/scope.ts @@ -1,5 +1,5 @@ import type { CreateScope, Scope } from '@logto/schemas'; -import { Scopes } from '@logto/schemas'; +import { Resources, Scopes } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; @@ -12,7 +12,8 @@ import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { Search } from '#src/utils/search.js'; import { buildConditionsFromSearch } from '#src/utils/search.js'; -const { table, fields } = convertToIdentifiers(Scopes); +const { table, fields } = convertToIdentifiers(Scopes, true); +const resources = convertToIdentifiers(Resources, true); const buildResourceConditions = (search: Search) => { const hasSearch = search.matches.length > 0; @@ -99,16 +100,18 @@ export const createScopeQueries = (pool: CommonQueryMethods) => { `) : []; - const findScopesByIdsAndResourceId = async ( + const findScopesByIdsAndResourceIndicator = async ( scopeIds: string[], - resourceId: string + resourceIndicator: string ): Promise => scopeIds.length > 0 ? pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} + inner join ${resources.table} + on ${resources.fields.id} = ${fields.resourceId} where ${fields.id} in (${sql.join(scopeIds, sql`, `)}) - and ${fields.resourceId} = ${resourceId} + and ${resources.fields.indicator} = ${resourceIndicator} `) : []; @@ -152,7 +155,7 @@ export const createScopeQueries = (pool: CommonQueryMethods) => { findScopesByResourceId, findScopesByResourceIds, findScopesByIds, - findScopesByIdsAndResourceId, + findScopesByIdsAndResourceIndicator, insertScope, findScopeById, updateScope, diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 0b4ec38a6..eb3daa3f7 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -140,12 +140,11 @@ export default function adminUserRoutes( primaryEmail: string().regex(emailRegEx), username: string().regex(usernameRegEx), password: string().regex(passwordRegEx), - isAdmin: boolean(), name: string(), }).partial(), }), async (ctx, next) => { - const { primaryEmail, primaryPhone, username, password, name, isAdmin } = ctx.guard.body; + const { primaryEmail, primaryPhone, username, password, name } = ctx.guard.body; assertThat( !username || !(await hasUser(username)), @@ -177,7 +176,7 @@ export default function adminUserRoutes( name, ...conditional(password && (await encryptUserPassword(password))), }, - isAdmin + [] ); ctx.body = pick(user, ...userInfoSelectFields); diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index aa888c0a2..8998637d7 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -1,5 +1,5 @@ import { generateStandardId, buildIdGenerator } from '@logto/core-kit'; -import { adminRoleId, Applications } from '@logto/schemas'; +import { defaultManagementApi, Applications } from '@logto/schemas'; import { boolean, object, string } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -76,7 +76,7 @@ export default function applicationRoutes( ctx.body = { ...application, - isAdmin: applicationsRoles.some(({ roleId }) => roleId === adminRoleId), + isAdmin: applicationsRoles.some(({ roleId }) => roleId === defaultManagementApi.role.id), }; return next(); @@ -107,14 +107,16 @@ export default function applicationRoutes( // FIXME @sijie temp solution to set admin access to machine to machine app if (isAdmin !== undefined) { const applicationsRoles = await findApplicationsRolesByApplicationId(id); - const originalIsAdmin = applicationsRoles.some(({ roleId }) => roleId === adminRoleId); + const originalIsAdmin = applicationsRoles.some( + ({ roleId }) => roleId === defaultManagementApi.role.id + ); if (isAdmin && !originalIsAdmin) { await insertApplicationsRoles([ - { id: generateStandardId(), applicationId: id, roleId: adminRoleId }, + { id: generateStandardId(), applicationId: id, roleId: defaultManagementApi.role.id }, ]); } else if (!isAdmin && originalIsAdmin) { - await deleteApplicationRole(id, adminRoleId); + await deleteApplicationRole(id, defaultManagementApi.role.id); } } diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index a80f0c064..8d44ce548 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -1,7 +1,8 @@ -import { managementResourceScope } from '@logto/schemas'; +import cors from '@koa/cors'; import Koa from 'koa'; import Router from 'koa-router'; +import { EnvSet } from '#src/env-set/index.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import koaAuth from '../middleware/koa-auth.js'; @@ -33,7 +34,7 @@ const createRouters = (tenant: TenantContext) => { interactionRoutes(interactionRouter, tenant); const managementRouter: AuthedRouter = new Router(); - managementRouter.use(koaAuth(tenant.envSet, managementResourceScope.name)); + managementRouter.use(koaAuth(tenant.envSet)); applicationRoutes(managementRouter, tenant); logtoConfigRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant); @@ -64,6 +65,19 @@ const createRouters = (tenant: TenantContext) => { export default function initRouter(tenant: TenantContext): Koa { const apisApp = new Koa(); + apisApp.use( + cors({ + origin: (ctx) => { + const { origin } = ctx.request.headers; + + return origin && + EnvSet.values.adminUrlSet.deduplicated().some((value) => new URL(value).origin === origin) + ? origin + : ''; + }, + }) + ); + for (const router of createRouters(tenant)) { apisApp.use(router.routes()).use(router.allowedMethods()); } diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 5ccc1f6ce..72eaa911b 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -1,5 +1,11 @@ import type { User, Profile } from '@logto/schemas'; -import { InteractionEvent, adminConsoleApplicationId } from '@logto/schemas'; +import { + getManagementApiAdminName, + defaultTenantId, + adminTenantId, + InteractionEvent, + adminConsoleApplicationId, +} from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import type { ConnectorLibrary } from '#src/libraries/connector.js'; @@ -7,6 +13,7 @@ import { assignInteractionResults } from '#src/libraries/session.js'; import { encryptUserPassword } from '#src/libraries/user.js'; import type { LogEntry } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import { getTenantId } from '#src/utils/tenant.js'; import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import type { @@ -157,14 +164,16 @@ export default async function submitInteraction( const { client_id } = ctx.interactionDetails.params; const createAdminUser = - String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers()); + getTenantId(ctx.URL) === adminTenantId && + String(client_id) === adminConsoleApplicationId && + !(await hasActiveUsers()); await insertUser( { id, ...upsertProfile, }, - createAdminUser + createAdminUser ? [getManagementApiAdminName(defaultTenantId)] : [] ); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index 8e77e61aa..ee14998c6 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -1,7 +1,8 @@ import { adminConsoleApplicationId, - managementResourceId, - managementResourceScope, + defaultTenantId, + getManagementApiResourceIndicator, + managementApiScopeAll, } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import type Router from 'koa-router'; @@ -35,13 +36,13 @@ export default function consentRoutes( // Block non-admin user from consenting to admin console if (String(client_id) === adminConsoleApplicationId) { - const scopes = await libraries.users.findUserScopesForResourceId( + const scopes = await libraries.users.findUserScopesForResourceIndicator( accountId, - managementResourceId + getManagementApiResourceIndicator(defaultTenantId) ); assertThat( - scopes.some(({ name }) => name === managementResourceScope.name), + scopes.some(({ name }) => name === managementApiScopeAll), new RequestError({ code: 'auth.forbidden', status: 401 }) ); } diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 3266580ec..651022377 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -3,6 +3,7 @@ import { ConnectorType } from '@logto/connector-kit'; import { adminConsoleApplicationId } from '@logto/schemas'; import etag from 'etag'; +import { EnvSet } from '#src/env-set/index.js'; import { getApplicationIdFromInteraction } from '#src/libraries/session.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; @@ -15,6 +16,15 @@ export default function wellKnownRoutes( connectors: { getLogtoConnectors }, } = libraries; + router.get('/.well-known/endpoints', async (ctx, next) => { + ctx.body = { + console: EnvSet.values.adminUrlSet.endpoint, + app: EnvSet.values.urlSet.endpoint, + }; + + return next(); + }); + router.get( '/.well-known/sign-in-exp', async (ctx, next) => { diff --git a/packages/core/src/utils/tenant.ts b/packages/core/src/utils/tenant.ts new file mode 100644 index 000000000..de123cd2e --- /dev/null +++ b/packages/core/src/utils/tenant.ts @@ -0,0 +1,33 @@ +import { adminTenantId, defaultTenantId } from '@logto/schemas'; + +import { EnvSet } from '#src/env-set/index.js'; + +export const getTenantId = (url: URL) => { + const { + isDomainBasedMultiTenancy, + isProduction, + isIntegrationTest, + developmentTenantId, + urlSet, + adminUrlSet, + } = EnvSet.values; + + if ((!isProduction || isIntegrationTest) && developmentTenantId) { + return developmentTenantId; + } + + const urlString = url.toString(); + + if (adminUrlSet.deduplicated().some((value) => urlString.startsWith(value))) { + return adminTenantId; + } + + if ( + !isDomainBasedMultiTenancy || + (!urlSet.isLocalhostDisabled && urlString.startsWith(urlSet.localhostUrl)) + ) { + return defaultTenantId; + } + + return new RegExp(urlSet.endpoint.replace('*', '([^.]*)')).exec(urlString)?.[1]; +}; diff --git a/packages/integration-tests/src/api/me.ts b/packages/integration-tests/src/api/me.ts deleted file mode 100644 index 28862076c..000000000 --- a/packages/integration-tests/src/api/me.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ArbitraryObject, UserInfo } from '@logto/schemas'; - -import api from './api.js'; - -export const getCurrentUserInfo = (userId: string) => - api.get(`me`, { headers: { 'development-user-id': userId } }).json(); - -export const getCurrentUserCustomData = (userId: string) => - api - .get('me/custom-data', { - headers: { - 'development-user-id': userId, - }, - }) - .json(); - -export const updateCurrentUserCustomData = (userId: string, payload: Record) => - api.patch('me/custom-data', { - headers: { - 'development-user-id': userId, - }, - json: { - customData: payload, - }, - }); - -export const changeCurrentUserPassword = (userId: string, password: string) => - api.patch('me/password', { - headers: { - 'development-user-id': userId, - }, - json: { - password, - }, - }); diff --git a/packages/schemas/src/seeds/index.ts b/packages/schemas/src/seeds/index.ts index bd3603f18..4e88b5a3f 100644 --- a/packages/schemas/src/seeds/index.ts +++ b/packages/schemas/src/seeds/index.ts @@ -1,7 +1,5 @@ export * from './application.js'; -export * from './resource.js'; +export * from './management-api.js'; export * from './logto-config.js'; export * from './sign-in-experience.js'; -export * from './roles.js'; -export * from './scope.js'; export * from './tenant.js'; diff --git a/packages/schemas/src/seeds/logto-config.ts b/packages/schemas/src/seeds/logto-config.ts index 95da2ea10..5be364eb0 100644 --- a/packages/schemas/src/seeds/logto-config.ts +++ b/packages/schemas/src/seeds/logto-config.ts @@ -2,12 +2,14 @@ import { CreateLogtoConfig } from '../db-entries/index.js'; import { AppearanceMode } from '../foundations/index.js'; import type { AdminConsoleData } from '../types/index.js'; import { AdminConsoleConfigKey } from '../types/index.js'; +import { defaultTenantId } from './tenant.js'; export const createDefaultAdminConsoleConfig = (): Readonly<{ key: AdminConsoleConfigKey; value: AdminConsoleData; }> => Object.freeze({ + tenantId: defaultTenantId, key: AdminConsoleConfigKey.AdminConsole, value: { language: 'en', diff --git a/packages/schemas/src/seeds/management-api.ts b/packages/schemas/src/seeds/management-api.ts new file mode 100644 index 000000000..3f34dcfaf --- /dev/null +++ b/packages/schemas/src/seeds/management-api.ts @@ -0,0 +1,82 @@ +import { generateStandardId } from '@logto/core-kit'; + +import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js'; +import { UserRole } from '../types/index.js'; +import { adminTenantId, defaultTenantId } from './tenant.js'; + +export type AdminData = { + resource: CreateResource; + scope: CreateScope; + role: CreateRole; +}; + +export const managementApiScopeAll = 'all'; + +// Consider remove the dependency of IDs +const defaultResourceId = 'management-api'; +const defaultScopeAllId = 'management-api-all'; + +// Consider combine this with `createManagementApiInAdminTenant()` +/** The fixed Management API Resource for `default` tenant. */ +export const defaultManagementApi = Object.freeze({ + resource: { + tenantId: defaultTenantId, + /** @deprecated You should not rely on this constant. Change to something else. */ + id: defaultResourceId, + /** + * The fixed resource indicator for Management APIs. + * + * Admin Console requires the access token of this resource to be functional. + */ + indicator: 'https://logto.app/api', + name: 'Logto Management API', + }, + scope: { + tenantId: defaultTenantId, + /** @deprecated You should not rely on this constant. Change to something else. */ + id: defaultScopeAllId, + name: managementApiScopeAll, + description: 'Default scope for Management API, allows all permissions.', + /** @deprecated You should not rely on this constant. Change to something else. */ + resourceId: defaultResourceId, + }, + role: { + tenantId: defaultTenantId, + /** @deprecated You should not rely on this constant. Change to something else. */ + id: 'admin-role', + name: UserRole.Admin, + description: 'Admin role for Logto.', + }, +}) satisfies AdminData; + +export const getManagementApiResourceIndicator = (tenantId: string) => + `https://${tenantId}.logto.app/api`; + +export const getManagementApiAdminName = (tenantId: string) => `${tenantId}:${UserRole.Admin}`; + +/** Create a Management API Resource of the given tenant ID for `admin` tenant. */ +export const createManagementApiInAdminTenant = (tenantId: string): AdminData => { + const resourceId = generateStandardId(); + + return Object.freeze({ + resource: { + tenantId: adminTenantId, + id: resourceId, + indicator: getManagementApiResourceIndicator(tenantId), + name: `Logto Management API for tenant ${tenantId}`, + }, + scope: { + tenantId: adminTenantId, + id: generateStandardId(), + name: managementApiScopeAll, + description: 'Default scope for Management API, allows all permissions.', + resourceId, + }, + role: { + tenantId: adminTenantId, + id: generateStandardId(), + name: getManagementApiAdminName(tenantId), + description: 'Admin role for Logto.', + }, + }); +}; diff --git a/packages/schemas/src/seeds/resource.ts b/packages/schemas/src/seeds/resource.ts deleted file mode 100644 index d1f4bfff6..000000000 --- a/packages/schemas/src/seeds/resource.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CreateResource } from '../db-entries/index.js'; -import { defaultTenantId } from './tenant.js'; - -export const managementResourceId = 'management-api'; - -export const managementResource: Readonly = Object.freeze({ - tenantId: defaultTenantId, - id: managementResourceId, - /** - * The fixed resource indicator for Management APIs. - * - * Admin Console requires the access token of this resource to be functional. - */ - indicator: 'https://api.logto.io', - name: 'Logto Management API', -}); diff --git a/packages/schemas/src/seeds/roles.ts b/packages/schemas/src/seeds/roles.ts deleted file mode 100644 index 5b5c0fb21..000000000 --- a/packages/schemas/src/seeds/roles.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CreateRole, CreateRolesScope } from '../db-entries/index.js'; -import { UserRole } from '../types/index.js'; -import { managementApiScopeAll } from './scope.js'; -import { defaultTenantId } from './tenant.js'; - -export const adminRoleId = 'admin-role'; -export const adminRoleScopeId = 'admin-role-scope'; - -/** - * Default Admin Role for Admin Console. - */ -export const defaultRole: Readonly = { - tenantId: defaultTenantId, - id: adminRoleId, - name: UserRole.Admin, - description: 'Admin role for Logto.', -}; - -export const defaultRoleScopeRelation: Readonly = { - id: adminRoleScopeId, - tenantId: defaultTenantId, - roleId: adminRoleId, - scopeId: managementApiScopeAll, -}; diff --git a/packages/schemas/src/seeds/scope.ts b/packages/schemas/src/seeds/scope.ts deleted file mode 100644 index 7d0cb9a44..000000000 --- a/packages/schemas/src/seeds/scope.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CreateScope } from '../db-entries/index.js'; -import { managementResourceId } from './resource.js'; -import { defaultTenantId } from './tenant.js'; - -export const managementApiScopeAll = 'management-api-all'; - -export const managementResourceScope: Readonly = Object.freeze({ - tenantId: defaultTenantId, - id: managementApiScopeAll, - name: 'all', - description: 'Default scope for Management API, allows all permissions.', - resourceId: managementResourceId, -}); diff --git a/packages/schemas/tables/_functions.sql b/packages/schemas/tables/_functions.sql index 0977b5af0..ccb7a9ef9 100644 --- a/packages/schemas/tables/_functions.sql +++ b/packages/schemas/tables/_functions.sql @@ -2,14 +2,14 @@ create function set_tenant_id() returns trigger as $$ begin - select tenants.id into new.tenant_id - from tenants - where ('tenant_user_' || tenants.id) = current_user; - - if new.tenant_id is null then - new.tenant_id := 'default'; + if new.tenant_id is not null then + return new; end if; + select tenants.id into new.tenant_id + from tenants + where tenants.db_user = current_user; + return new; end; $$ language plpgsql; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b72286380..9c179a3f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,7 @@ importers: packages/core: specifiers: + '@koa/cors': ^4.0.0 '@logto/cli': workspace:* '@logto/connector-kit': workspace:* '@logto/core-kit': workspace:* @@ -271,6 +272,7 @@ importers: '@types/koa-logger': ^3.1.1 '@types/koa-mount': ^4.0.0 '@types/koa-send': ^4.1.3 + '@types/koa__cors': ^3.3.0 '@types/node': ^18.11.18 '@types/oidc-provider': ^8.0.0 '@types/semver': ^7.3.12 @@ -326,6 +328,7 @@ importers: typescript: ^4.9.4 zod: ^3.20.2 dependencies: + '@koa/cors': 4.0.0 '@logto/cli': link:../cli '@logto/connector-kit': link:../toolkit/connector-kit '@logto/core-kit': link:../toolkit/core-kit @@ -384,6 +387,7 @@ importers: '@types/koa-logger': 3.1.2 '@types/koa-mount': 4.0.1 '@types/koa-send': 4.1.3 + '@types/koa__cors': 3.3.0 '@types/node': 18.11.18 '@types/oidc-provider': 8.0.0 '@types/semver': 7.3.12 @@ -4099,6 +4103,12 @@ packages: '@types/node': 18.11.18 dev: true + /@types/koa__cors/3.3.0: + resolution: {integrity: sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA==} + dependencies: + '@types/koa': 2.13.4 + dev: true + /@types/mdast/3.0.10: resolution: {integrity: sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==} dependencies: