mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #3092 from logto-io/gao-add-admin-tenant
refactor!: add admin tenant
This commit is contained in:
commit
b1826d7bcc
88 changed files with 1425 additions and 527 deletions
|
@ -117,12 +117,32 @@ assert.deepStrictEqual(...manifests);
|
|||
const queryDatabaseData = async (database) => {
|
||||
const pool = new pg.Pool({ database, user: 'postgres', password: 'postgres' });
|
||||
const result = await Promise.all(manifests[0].tables
|
||||
// system configs are usually generated or time-relative, ignore for now
|
||||
.filter(({ table_name }) => !['logto_configs', '_logto_configs', 'systems'].includes(table_name))
|
||||
.map(async ({ table_name }) => {
|
||||
const { rows } = await pool.query(/* sql */`select * from ${table_name};`);
|
||||
|
||||
return [table_name, omitArray(rows, 'created_at', 'updated_at', 'secret', 'db_user', 'db_user_password')];
|
||||
// check config rows except the value column
|
||||
if (['logto_configs', '_logto_configs', 'systems'].includes(table_name)) {
|
||||
return [table_name, omitArray(rows, 'value').sort((a, b) => {
|
||||
if (a.tenant_id === b.tenant_id) {
|
||||
return a.key.localeCompare(b.key);
|
||||
}
|
||||
|
||||
return a.tenant_id.localeCompare(b.tenant_id);
|
||||
})];
|
||||
}
|
||||
|
||||
return [table_name, omitArray(
|
||||
rows,
|
||||
'id',
|
||||
'resource_id',
|
||||
'role_id',
|
||||
'scope_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'secret',
|
||||
'db_user',
|
||||
'db_user_password'
|
||||
)];
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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<unknown, { key: string; keys: string[] }> = {
|
||||
const getConfig: CommandModule<unknown, { key: string; keys: string[]; tenantId: string }> = {
|
||||
command: 'get <key> [keys...]',
|
||||
describe: 'Get config value(s) of the given key(s) in Logto database',
|
||||
builder: (yargs) =>
|
||||
|
@ -60,13 +65,18 @@ const getConfig: CommandModule<unknown, { key: string; keys: string[] }> = {
|
|||
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<unknown, { key: string; keys: string[] }> = {
|
|||
},
|
||||
};
|
||||
|
||||
const setConfig: CommandModule<unknown, { key: string; value: string }> = {
|
||||
const setConfig: CommandModule<unknown, { key: string; value: string; tenantId: string }> = {
|
||||
command: 'set <key> <value>',
|
||||
describe: 'Set config value of the given key in Logto database',
|
||||
builder: (yargs) =>
|
||||
|
@ -99,35 +109,46 @@ const setConfig: CommandModule<unknown, { key: string; value: string }> = {
|
|||
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<unknown, { key: string }> = {
|
||||
const rotateConfig: CommandModule<unknown, { key: string; tenantId: string }> = {
|
||||
command: 'rotate <key>',
|
||||
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<unknown, { key: string }> = {
|
|||
}
|
||||
};
|
||||
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<unknown, { key: string; length: number }> = {
|
||||
const trimConfig: CommandModule<unknown, { key: string; length: number; tenantId: string }> = {
|
||||
command: 'trim <key> [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<unknown, { key: string; length: number }> = {
|
|||
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<unknown, { key: string; length: number }> = {
|
|||
}
|
||||
|
||||
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<unknown, { key: string; length: number }> = {
|
|||
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`);
|
||||
|
|
|
@ -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 '../../../utils.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<Promise<LogtoOidcConfigKey | undefined>>(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);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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 '../../../utils.js';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utils.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<Promise<LogtoOidcConfigKey | undefined>>(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)
|
||||
|
|
|
@ -3,14 +3,14 @@ import path from 'path';
|
|||
|
||||
import { generateStandardId } from '@logto/core-kit';
|
||||
import {
|
||||
managementResource,
|
||||
defaultSignInExperience,
|
||||
createDefaultAdminConsoleConfig,
|
||||
createDemoAppApplication,
|
||||
defaultRole,
|
||||
managementResourceScope,
|
||||
defaultRoleScopeRelation,
|
||||
defaultTenantId,
|
||||
adminTenantId,
|
||||
defaultManagementApi,
|
||||
createManagementApiInAdminTenant,
|
||||
createMeApiInAdminTenant,
|
||||
} from '@logto/schemas';
|
||||
import { Hooks, Tenants } from '@logto/schemas/models';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
|
@ -21,7 +21,8 @@ import { insertInto } from '../../../database.js';
|
|||
import { getDatabaseName } from '../../../queries/database.js';
|
||||
import { updateDatabaseTimestamp } from '../../../queries/system.js';
|
||||
import { getPathInModule } from '../../../utils.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 +115,18 @@ 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 seedAdminData(connection, createMeApiInAdminTenant());
|
||||
|
||||
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),
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { generateKeyPair } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
export const generateOidcPrivateKey = async (type: 'rsa' | 'ec' = 'ec') => {
|
||||
if (type === 'rsa') {
|
||||
|
@ -41,4 +41,4 @@ export const generateOidcPrivateKey = async (type: 'rsa' | 'ec' = 'ec') => {
|
|||
throw new Error(`Unsupported private key ${String(type)}`);
|
||||
};
|
||||
|
||||
export const generateOidcCookieKey = () => nanoid();
|
||||
export const generateOidcCookieKey = () => generateStandardId();
|
||||
|
|
|
@ -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<LogtoConfig>(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 <T extends LogtoConfigKey>(
|
||||
pool: CommonQueryMethods,
|
||||
tenantId: string,
|
||||
key: T,
|
||||
value: z.infer<typeof logtoConfigGuards[T]>
|
||||
) =>
|
||||
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}
|
||||
`
|
||||
|
|
|
@ -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, meApi } from '@/consts/management-api';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import AppLayout from '@/containers/AppLayout';
|
||||
import ErrorBoundary from '@/containers/ErrorBoundary';
|
||||
|
@ -46,8 +45,10 @@ import {
|
|||
RoleDetailsTabs,
|
||||
SignInExperiencePage,
|
||||
UserDetailsTabs,
|
||||
} from './consts/page-tabs';
|
||||
adminTenantEndpoint,
|
||||
} from './consts';
|
||||
import AppContent from './containers/AppContent';
|
||||
import AppEndpointsProvider, { AppEndpointsContext } from './containers/AppEndpointsProvider';
|
||||
import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions';
|
||||
import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings';
|
||||
import CloudPreview from './pages/CloudPreview';
|
||||
|
@ -65,6 +66,11 @@ void initI18n();
|
|||
|
||||
const Main = () => {
|
||||
const swrOptions = useSwrOptions();
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
if (!userEndpoint) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
@ -159,16 +165,18 @@ const Main = () => {
|
|||
|
||||
const App = () => (
|
||||
<BrowserRouter basename={getBasename('console', '5002')}>
|
||||
<LogtoProvider
|
||||
config={{
|
||||
endpoint: window.location.origin,
|
||||
appId: adminConsoleApplicationId,
|
||||
resources: [managementResource.indicator],
|
||||
scopes: [UserScope.Identities, UserScope.CustomData, managementResourceScope.name],
|
||||
}}
|
||||
>
|
||||
<Main />
|
||||
</LogtoProvider>
|
||||
<AppEndpointsProvider>
|
||||
<LogtoProvider
|
||||
config={{
|
||||
endpoint: adminTenantEndpoint,
|
||||
appId: adminConsoleApplicationId,
|
||||
resources: [managementApi.indicator, meApi.indicator],
|
||||
scopes: [UserScope.Identities, UserScope.CustomData, managementApi.scopeAll],
|
||||
}}
|
||||
>
|
||||
<Main />
|
||||
</LogtoProvider>
|
||||
</AppEndpointsProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { ResourceResponse, Scope, ScopeResponse } from '@logto/schemas';
|
||||
import { managementApiScopeAll } from '@logto/schemas';
|
||||
import { PredefinedScope } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
@ -79,7 +79,7 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
|
|||
}
|
||||
|
||||
const existingScopeIds = roleScopes?.map(({ id }) => id) ?? [];
|
||||
const excludeScopeIds = new Set([...existingScopeIds, managementApiScopeAll]);
|
||||
const excludeScopeIds = new Set([...existingScopeIds, PredefinedScope.All]);
|
||||
|
||||
return allResources
|
||||
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.has(id)))
|
||||
|
|
|
@ -35,7 +35,6 @@ const SourceUsersBox = ({ roleId, selectedUsers, onChange }: Props) => {
|
|||
|
||||
const url = buildUrl('api/users', {
|
||||
excludeRoleId: roleId,
|
||||
hideAdminUser: String(true),
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
...conditional(keyword && { search: formatSearchKeyword(keyword) }),
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
export * from './applications';
|
||||
export * from './connectors';
|
||||
export * from './logs';
|
||||
export * from './management-api';
|
||||
export * from './tenants';
|
||||
export * from './page-tabs';
|
||||
|
||||
export const themeStorageKey = 'logto:admin_console:theme';
|
||||
export const requestTimeout = 20_000;
|
||||
|
|
16
packages/console/src/consts/management-api.ts
Normal file
16
packages/console/src/consts/management-api.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {
|
||||
adminTenantId,
|
||||
defaultTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
PredefinedScope,
|
||||
} from '@logto/schemas';
|
||||
|
||||
export const managementApi = Object.freeze({
|
||||
indicator: getManagementApiResourceIndicator(defaultTenantId),
|
||||
scopeAll: PredefinedScope.All,
|
||||
});
|
||||
|
||||
export const meApi = Object.freeze({
|
||||
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
|
||||
scopeAll: PredefinedScope.All,
|
||||
});
|
4
packages/console/src/consts/tenants.ts
Normal file
4
packages/console/src/consts/tenants.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { defaultTenantId } from '@logto/schemas';
|
||||
|
||||
export const adminTenantEndpoint = process.env.ADMIN_TENANT_ENDPOINT ?? window.location.origin;
|
||||
export const userTenantId = process.env.USER_TENANT_ID ?? defaultTenantId;
|
|
@ -0,0 +1,40 @@
|
|||
import ky from 'ky';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useEffect, createContext, useState } from 'react';
|
||||
|
||||
import { adminTenantEndpoint, userTenantId } from '@/consts';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type AppEndpoints = {
|
||||
userEndpoint?: URL;
|
||||
adminEndpoint?: URL;
|
||||
};
|
||||
|
||||
export type AppEndpointKey = keyof AppEndpoints;
|
||||
|
||||
export const AppEndpointsContext = createContext<AppEndpoints>({});
|
||||
|
||||
const AppEndpointsProvider = ({ children }: Props) => {
|
||||
const [endpoints, setEndpoints] = useState<AppEndpoints>({});
|
||||
const memorizedContext = useMemo(() => endpoints, [endpoints]);
|
||||
|
||||
useEffect(() => {
|
||||
const getEndpoint = async () => {
|
||||
const { user } = await ky
|
||||
.get(new URL(`api/.well-known/endpoints/${userTenantId}`, adminTenantEndpoint))
|
||||
.json<{ user: string }>();
|
||||
setEndpoints({ userEndpoint: new URL(user) });
|
||||
};
|
||||
|
||||
void getEndpoint();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppEndpointsContext.Provider value={memorizedContext}>{children}</AppEndpointsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppEndpointsProvider;
|
|
@ -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 { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
|
||||
export class RequestError extends Error {
|
||||
status: number;
|
||||
|
@ -19,11 +19,17 @@ export class RequestError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type StaticApiProps = {
|
||||
prefixUrl: string;
|
||||
hideErrorToast?: boolean;
|
||||
resourceIndicator?: string;
|
||||
};
|
||||
|
||||
const useApi = ({ hideErrorToast }: Props = {}) => {
|
||||
export const useStaticApi = ({
|
||||
prefixUrl,
|
||||
hideErrorToast,
|
||||
resourceIndicator = managementApi.indicator,
|
||||
}: StaticApiProps) => {
|
||||
const { isAuthenticated, getAccessToken } = useLogto();
|
||||
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
|
@ -44,7 +50,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
const api = useMemo(
|
||||
() =>
|
||||
ky.create({
|
||||
prefixUrl: window.location.origin,
|
||||
prefixUrl,
|
||||
timeout: requestTimeout,
|
||||
hooks: {
|
||||
beforeError: hideErrorToast
|
||||
|
@ -59,7 +65,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
beforeRequest: [
|
||||
async (request) => {
|
||||
if (isAuthenticated) {
|
||||
const accessToken = await getAccessToken(managementResource.indicator);
|
||||
const accessToken = await getAccessToken(resourceIndicator);
|
||||
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
|
||||
request.headers.set('Accept-Language', i18n.language);
|
||||
}
|
||||
|
@ -67,10 +73,24 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
],
|
||||
},
|
||||
}),
|
||||
[hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language]
|
||||
[
|
||||
prefixUrl,
|
||||
hideErrorToast,
|
||||
toastError,
|
||||
isAuthenticated,
|
||||
getAccessToken,
|
||||
resourceIndicator,
|
||||
i18n.language,
|
||||
]
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
const useApi = (props: Omit<StaticApiProps, 'prefixUrl'> = {}) => {
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
return useStaticApi({ ...props, prefixUrl: userEndpoint?.toString() ?? '' });
|
||||
};
|
||||
|
||||
export default useApi;
|
||||
|
|
|
@ -6,17 +6,17 @@ import type { BareFetcher } from 'swr';
|
|||
|
||||
import useApi, { RequestError } from './use-api';
|
||||
|
||||
type withTotalNumber<T> = Array<Awaited<T> | number>;
|
||||
type WithTotalNumber<T> = Array<Awaited<T> | number>;
|
||||
|
||||
type useSwrFetcherHook = {
|
||||
<T>(): BareFetcher<T>;
|
||||
<T extends unknown[]>(): BareFetcher<withTotalNumber<T>>;
|
||||
<T extends unknown[]>(): BareFetcher<WithTotalNumber<T>>;
|
||||
};
|
||||
|
||||
const useSwrFetcher: useSwrFetcherHook = <T>() => {
|
||||
const api = useApi({ hideErrorToast: true });
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const fetcher = useCallback<BareFetcher<T | withTotalNumber<T>>>(
|
||||
const fetcher = useCallback<BareFetcher<T | WithTotalNumber<T>>>(
|
||||
async (resource, init) => {
|
||||
try {
|
||||
const response = await api.get(resource, init);
|
||||
|
|
|
@ -5,13 +5,14 @@ import type { Nullable, Optional } from '@silverhand/essentials';
|
|||
import { t } from 'i18next';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import type { BareFetcher } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { themeStorageKey } from '@/consts';
|
||||
import { meApi, themeStorageKey, adminTenantEndpoint } from '@/consts';
|
||||
|
||||
import type { RequestError } from './use-api';
|
||||
import useApi from './use-api';
|
||||
import { useStaticApi } from './use-api';
|
||||
import useLogtoUserId from './use-logto-user-id';
|
||||
|
||||
const userPreferencesGuard = z.object({
|
||||
|
@ -35,10 +36,19 @@ const useUserPreferences = () => {
|
|||
const { isAuthenticated, error: authError } = useLogto();
|
||||
const userId = useLogtoUserId();
|
||||
const shouldFetch = isAuthenticated && !authError && userId;
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `api/users/${userId}/custom-data`
|
||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||
const fetcher = useCallback<BareFetcher>(
|
||||
async (resource, init) => {
|
||||
const response = await api.get(resource, init);
|
||||
|
||||
return response.json();
|
||||
},
|
||||
[api]
|
||||
);
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `me/custom-data`,
|
||||
fetcher
|
||||
);
|
||||
const api = useApi();
|
||||
|
||||
const parseData = useCallback((): UserPreferences => {
|
||||
try {
|
||||
|
@ -62,13 +72,11 @@ const useUserPreferences = () => {
|
|||
}
|
||||
|
||||
const updated = await api
|
||||
.patch(`api/users/${userId}/custom-data`, {
|
||||
.patch(`me/custom-data`, {
|
||||
json: {
|
||||
customData: {
|
||||
[key]: {
|
||||
...userPreferences,
|
||||
...data,
|
||||
},
|
||||
[key]: {
|
||||
...userPreferences,
|
||||
...data,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -4,10 +4,11 @@ import type { Optional } from '@silverhand/essentials';
|
|||
import i18next from 'i18next';
|
||||
import type { MDXProps } from 'mdx/types';
|
||||
import type { LazyExoticComponent } from 'react';
|
||||
import { cloneElement, lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useContext, cloneElement, lazy, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
import DetailsSummary from '@/mdx-components/DetailsSummary';
|
||||
import type { SupportedSdk } from '@/types/applications';
|
||||
import { applicationTypeAndSdkTypeMappings } from '@/types/applications';
|
||||
|
@ -53,6 +54,7 @@ const Guide = ({ app, isCompact, onClose }: Props) => {
|
|||
const sdks = applicationTypeAndSdkTypeMappings[appType];
|
||||
const [selectedSdk, setSelectedSdk] = useState<Optional<SupportedSdk>>(sdks[0]);
|
||||
const [activeStepIndex, setActiveStepIndex] = useState(-1);
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
// Directly close guide if no SDK available
|
||||
useEffect(() => {
|
||||
|
@ -110,7 +112,7 @@ const Guide = ({ app, isCompact, onClose }: Props) => {
|
|||
<GuideComponent
|
||||
appId={appId}
|
||||
appSecret={appSecret}
|
||||
endpoint={window.location.origin}
|
||||
endpoint={userEndpoint}
|
||||
redirectUris={oidcClientMetadata.redirectUris}
|
||||
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
|
||||
activeStepIndex={activeStepIndex}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import type { Application } from '@logto/schemas';
|
||||
import { AppearanceMode, demoAppApplicationId } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -18,6 +18,7 @@ import Passwordless from '@/assets/images/passwordless.svg';
|
|||
import SocialDark from '@/assets/images/social-dark.svg';
|
||||
import Social from '@/assets/images/social.svg';
|
||||
import { ConnectorsTabs } from '@/consts/page-tabs';
|
||||
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import useConfigs from '@/hooks/use-configs';
|
||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
|
@ -37,6 +38,7 @@ type GetStartedMetadata = {
|
|||
const useGetStartedMetadata = () => {
|
||||
const { getDocumentationUrl } = useDocumentationUrl();
|
||||
const { configs, updateConfigs } = useConfigs();
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const theme = useTheme();
|
||||
const isLightMode = theme === AppearanceMode.LightMode;
|
||||
const { data: demoApp, error } = useSWR<Application, RequestError>(
|
||||
|
@ -67,7 +69,7 @@ const useGetStartedMetadata = () => {
|
|||
isHidden: hideDemo,
|
||||
onClick: async () => {
|
||||
void updateConfigs({ demoChecked: true });
|
||||
window.open('/demo-app', '_blank');
|
||||
window.open(new URL('/demo-app', userEndpoint), '_blank');
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -133,17 +135,18 @@ const useGetStartedMetadata = () => {
|
|||
|
||||
return metadataItems.filter(({ isHidden }) => !isHidden);
|
||||
}, [
|
||||
getDocumentationUrl,
|
||||
hideDemo,
|
||||
isLightMode,
|
||||
navigate,
|
||||
configs?.applicationCreated,
|
||||
configs?.demoChecked,
|
||||
configs?.furtherReadingsChecked,
|
||||
configs?.passwordlessConfigured,
|
||||
configs?.applicationCreated,
|
||||
configs?.signInExperienceCustomized,
|
||||
configs?.passwordlessConfigured,
|
||||
configs?.socialSignInConfigured,
|
||||
configs?.furtherReadingsChecked,
|
||||
hideDemo,
|
||||
updateConfigs,
|
||||
userEndpoint,
|
||||
navigate,
|
||||
getDocumentationUrl,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
|
@ -5,13 +5,14 @@ import { ConnectorType, AppearanceMode } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import PhoneInfo from '@/assets/images/phone-info.svg';
|
||||
import Select from '@/components/Select';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
|
||||
|
@ -29,9 +30,8 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
const [platform, setPlatform] = useState<'desktopWeb' | 'mobile' | 'mobileWeb'>('desktopWeb');
|
||||
const { data: allConnectors } = useSWR<ConnectorResponse[], RequestError>('api/connectors');
|
||||
const previewRef = useRef<HTMLIFrameElement>(null);
|
||||
const { customPhrases } = useUiLanguages();
|
||||
|
||||
const { languages } = useUiLanguages();
|
||||
const { customPhrases, languages } = useUiLanguages();
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
const modeOptions = useMemo(() => {
|
||||
const light = { value: AppearanceMode.LightMode, title: t('sign_in_exp.preview.light') };
|
||||
|
@ -120,9 +120,9 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
|
||||
previewRef.current?.contentWindow?.postMessage(
|
||||
{ sender: 'ac_preview', config },
|
||||
window.location.origin
|
||||
userEndpoint?.origin ?? ''
|
||||
);
|
||||
}, [config, customPhrases]);
|
||||
}, [userEndpoint?.origin, config, customPhrases]);
|
||||
|
||||
useEffect(() => {
|
||||
postPreviewMessage();
|
||||
|
@ -210,7 +210,7 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
ref={previewRef}
|
||||
// Allow all sandbox rules
|
||||
sandbox={undefined}
|
||||
src="/sign-in?preview=true"
|
||||
src={new URL('/sign-in?preview=true', userEndpoint).toString()}
|
||||
tabIndex={-1}
|
||||
title={t('sign_in_exp.preview.title')}
|
||||
/>
|
||||
|
|
|
@ -39,7 +39,6 @@ const Users = () => {
|
|||
});
|
||||
|
||||
const url = buildUrl('api/users', {
|
||||
hideAdminUser: String(true),
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
...conditional(keyword && { search: formatSearchKeyword(keyword) }),
|
||||
|
|
|
@ -23,6 +23,9 @@ mockEsm('#src/env-set/check-alteration-state.js', () => ({
|
|||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
mockEsmDefault('#src/env-set/oidc.js', () => () => ({
|
||||
issuer: 'https://logto.test/oidc',
|
||||
cookieKeys: [],
|
||||
privateJwks: [],
|
||||
publicJwks: [],
|
||||
}));
|
||||
/* End */
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<void> {
|
||||
app.use(async (ctx, next) => {
|
||||
const tenantId = getTenantId();
|
||||
const tenantId = getTenantId(ctx.URL);
|
||||
|
||||
if (!tenantId) {
|
||||
ctx.status = 404;
|
||||
|
|
|
@ -37,12 +37,6 @@ export default class UrlSet {
|
|||
}
|
||||
|
||||
public get endpoint() {
|
||||
const value = this.#endpoint || this.localhostUrl;
|
||||
|
||||
if (this.isLocalhostDisabled && new URL(value).hostname === 'localhost') {
|
||||
throw new Error(localhostDisabledMessage);
|
||||
}
|
||||
|
||||
return value;
|
||||
return this.#endpoint || this.localhostUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
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';
|
||||
|
@ -12,14 +15,35 @@ import createQueryClient from './create-query-client.js';
|
|||
import loadOidcValues from './oidc.js';
|
||||
import { throwNotLoadedError } from './throw-errors.js';
|
||||
|
||||
export enum MountedApps {
|
||||
/** Apps (also paths) for user tenants. */
|
||||
export enum UserApps {
|
||||
Api = 'api',
|
||||
Oidc = 'oidc',
|
||||
Console = 'console',
|
||||
DemoApp = 'demo-app',
|
||||
}
|
||||
|
||||
/** Apps (also paths) ONLY for the admin tenant. */
|
||||
export enum AdminApps {
|
||||
Me = 'me',
|
||||
Console = 'console',
|
||||
Welcome = 'welcome',
|
||||
}
|
||||
|
||||
const getTenantEndpoint = (id: string) => {
|
||||
const { urlSet, adminUrlSet, isDomainBasedMultiTenancy } = EnvSet.values;
|
||||
const adminUrl = trySafe(() => adminUrlSet.endpoint);
|
||||
|
||||
if (adminUrl && id === adminTenantId) {
|
||||
return adminUrl;
|
||||
}
|
||||
|
||||
if (!isDomainBasedMultiTenancy) {
|
||||
return urlSet.endpoint;
|
||||
}
|
||||
|
||||
return urlSet.endpoint.replace('*', id);
|
||||
};
|
||||
|
||||
export class EnvSet {
|
||||
static values = new GlobalValues();
|
||||
|
||||
|
@ -42,7 +66,7 @@ export class EnvSet {
|
|||
#queryClient: Optional<QueryClient<PostgreSql>>;
|
||||
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
|
||||
|
||||
constructor(public readonly databaseUrl: string) {}
|
||||
constructor(public readonly tenantId: string, public readonly databaseUrl: string) {}
|
||||
|
||||
get pool() {
|
||||
if (!this.#pool) {
|
||||
|
@ -82,12 +106,10 @@ 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(
|
||||
appendPath(EnvSet.values.endpoint, '/oidc').toString(),
|
||||
oidcConfigs
|
||||
);
|
||||
const endpoint = getTenantEndpoint(this.tenantId);
|
||||
this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => {
|
|||
return Object.freeze({
|
||||
cookieKeys,
|
||||
privateJwks,
|
||||
publicJwks,
|
||||
jwkSigningAlg,
|
||||
localJWKSet,
|
||||
issuer,
|
||||
|
|
|
@ -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<readonly Scope[]> => {
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<LogtoOidcConfigType> => {
|
||||
try {
|
||||
const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey));
|
||||
const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey));
|
||||
|
||||
return z
|
||||
.object(logtoOidcConfigGuard)
|
||||
|
|
|
@ -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<SignInExperience & { notification?: string }> => {
|
||||
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;
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<typeof createUserLibrary>;
|
|||
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<CreateUser>, isAdmin = false) => {
|
||||
const roleNames = deduplicate([
|
||||
...EnvSet.values.userDefaultRoleNames,
|
||||
...(isAdmin ? [defaultRole.name] : []),
|
||||
]);
|
||||
const insertUser = async (data: OmitAutoSetFields<CreateUser>, 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<readonly Scope[]> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
@ -9,18 +9,24 @@ import RequestError from '#src/errors/RequestError/index.js';
|
|||
import { mockEnvSet } from '#src/test-utils/env-set.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { WithAuthContext } from './koa-auth.js';
|
||||
import type { WithAuthContext } from './index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
const { jwtVerify } = mockEsm('jose', () => ({
|
||||
jwtVerify: jest
|
||||
.fn()
|
||||
.mockReturnValue({ payload: { sub: 'fooUser', scope: managementResourceScope.name } }),
|
||||
mockEsm('./utils.js', () => ({
|
||||
getAdminTenantTokenValidationSet: jest.fn().mockResolvedValue({ keys: [], issuer: [] }),
|
||||
}));
|
||||
|
||||
const koaAuth = await pickDefault(import('./koa-auth.js'));
|
||||
const { jwtVerify } = mockEsm('jose', () => ({
|
||||
createLocalJWKSet: jest.fn(),
|
||||
jwtVerify: jest
|
||||
.fn()
|
||||
.mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }),
|
||||
}));
|
||||
|
||||
const audience = defaultManagementApi.resource.indicator;
|
||||
const koaAuth = await pickDefault(import('./index.js'));
|
||||
|
||||
describe('koaAuth middleware', () => {
|
||||
const baseCtx = createContextWithRouteParameters();
|
||||
|
@ -64,7 +70,7 @@ describe('koaAuth middleware', () => {
|
|||
developmentUserId: 'foo',
|
||||
});
|
||||
|
||||
await koaAuth(mockEnvSet)(ctx, next);
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -79,7 +85,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(mockEnvSet)(mockCtx, next);
|
||||
await koaAuth(mockEnvSet, audience)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
});
|
||||
|
||||
|
@ -91,7 +97,7 @@ describe('koaAuth middleware', () => {
|
|||
isIntegrationTest: true,
|
||||
});
|
||||
|
||||
await koaAuth(mockEnvSet)(ctx, next);
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -112,7 +118,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(mockEnvSet)(mockCtx, next);
|
||||
await koaAuth(mockEnvSet, audience)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
|
||||
stub.restore();
|
||||
|
@ -125,12 +131,14 @@ describe('koaAuth middleware', () => {
|
|||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
await koaAuth(mockEnvSet)(ctx, next);
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header is missing', async () => {
|
||||
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
|
||||
authHeaderMissingError
|
||||
);
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header token type not recognized ', async () => {
|
||||
|
@ -141,7 +149,9 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
|
||||
tokenNotSupportedError
|
||||
);
|
||||
});
|
||||
|
||||
it('expect to throw if jwt sub is missing', async () => {
|
||||
|
@ -154,11 +164,13 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
|
||||
});
|
||||
|
||||
it('expect to have `client` type per jwt verify result', async () => {
|
||||
jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } }));
|
||||
jwtVerify.mockImplementationOnce(() => ({
|
||||
payload: { sub: 'bar', client_id: 'bar', scope: 'all' },
|
||||
}));
|
||||
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
|
@ -167,7 +179,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await koaAuth(mockEnvSet)(ctx, next);
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' });
|
||||
});
|
||||
|
||||
|
@ -181,9 +193,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
|
||||
forbiddenError
|
||||
);
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(forbiddenError);
|
||||
});
|
||||
|
||||
it('expect to throw if jwt scope does not include management resource scope', async () => {
|
||||
|
@ -198,9 +208,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
|
||||
forbiddenError
|
||||
);
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(forbiddenError);
|
||||
});
|
||||
|
||||
it('expect to throw unauthorized error if unknown error occurs', async () => {
|
||||
|
@ -214,7 +222,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(
|
||||
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
|
||||
new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error'))
|
||||
);
|
||||
});
|
|
@ -1,8 +1,9 @@
|
|||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
import { managementResource, managementResourceScope } from '@logto/schemas';
|
||||
import { adminTenantId, defaultManagementApi, PredefinedScope } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { jwtVerify } from 'jose';
|
||||
import type { JWK } from 'jose';
|
||||
import { createLocalJWKSet, jwtVerify } from 'jose';
|
||||
import type { MiddlewareType, Request } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
@ -11,6 +12,8 @@ import { EnvSet } from '#src/env-set/index.js';
|
|||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { getAdminTenantTokenValidationSet } from './utils.js';
|
||||
|
||||
export type Auth = {
|
||||
type: 'user' | 'app';
|
||||
id: string;
|
||||
|
@ -23,7 +26,7 @@ export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamC
|
|||
|
||||
const bearerTokenIdentifier = 'Bearer';
|
||||
|
||||
const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
assertThat(
|
||||
authorization,
|
||||
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
|
||||
|
@ -48,23 +51,44 @@ type TokenInfo = {
|
|||
export const verifyBearerTokenFromRequest = async (
|
||||
envSet: EnvSet,
|
||||
request: Request,
|
||||
resourceIndicator: Optional<string>
|
||||
audience: Optional<string>
|
||||
): Promise<TokenInfo> => {
|
||||
const { isProduction, isIntegrationTest, developmentUserId } = EnvSet.values;
|
||||
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] };
|
||||
}
|
||||
|
||||
const getKeysAndIssuer = async (): Promise<[JWK[], string[]]> => {
|
||||
const { publicJwks, issuer } = envSet.oidc;
|
||||
|
||||
if (envSet.tenantId === adminTenantId) {
|
||||
return [publicJwks, [issuer]];
|
||||
}
|
||||
|
||||
const adminSet = await getAdminTenantTokenValidationSet();
|
||||
|
||||
return [
|
||||
[...publicJwks, ...adminSet.keys],
|
||||
[issuer, ...adminSet.issuer],
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
const { localJWKSet, issuer } = envSet.oidc;
|
||||
const [keys, issuer] = await getKeysAndIssuer();
|
||||
const {
|
||||
payload: { sub, client_id: clientId, scope = '' },
|
||||
} = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, {
|
||||
issuer,
|
||||
audience: resourceIndicator,
|
||||
});
|
||||
} = await jwtVerify(
|
||||
extractBearerTokenFromHeaders(request.headers),
|
||||
createLocalJWKSet({ keys }),
|
||||
{
|
||||
issuer,
|
||||
audience,
|
||||
}
|
||||
);
|
||||
|
||||
assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));
|
||||
|
||||
|
@ -80,21 +104,19 @@ export const verifyBearerTokenFromRequest = async (
|
|||
|
||||
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
envSet: EnvSet,
|
||||
forScope?: string
|
||||
audience: string
|
||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
|
||||
envSet,
|
||||
ctx.request,
|
||||
managementResource.indicator
|
||||
audience
|
||||
);
|
||||
|
||||
if (forScope) {
|
||||
assertThat(
|
||||
scopes.includes(forScope),
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
}
|
||||
assertThat(
|
||||
scopes.includes(PredefinedScope.All),
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
|
||||
ctx.auth = {
|
||||
type: sub === clientId ? 'app' : 'user',
|
54
packages/core/src/middleware/koa-auth/utils.ts
Normal file
54
packages/core/src/middleware/koa-auth/utils.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
import type { LogtoConfig } from '@logto/schemas';
|
||||
import {
|
||||
logtoOidcConfigGuard,
|
||||
adminTenantId,
|
||||
LogtoOidcConfigKey,
|
||||
LogtoConfigs,
|
||||
} from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { JWK } from 'jose';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { exportJWK } from '#src/utils/jwks.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(LogtoConfigs);
|
||||
|
||||
/**
|
||||
* This function is to fetch OIDC public signing keys and the issuer from the admin tenant
|
||||
* in order to let user tenants recognize Access Tokens issued by the admin tenant.
|
||||
*
|
||||
* Usually you don't mean to call this function.
|
||||
*/
|
||||
export const getAdminTenantTokenValidationSet = async (): Promise<{
|
||||
keys: JWK[];
|
||||
issuer: string[];
|
||||
}> => {
|
||||
const { isDomainBasedMultiTenancy, urlSet, adminUrlSet } = EnvSet.values;
|
||||
|
||||
if (!isDomainBasedMultiTenancy && adminUrlSet.deduplicated().length === 0) {
|
||||
return { keys: [], issuer: [] };
|
||||
}
|
||||
|
||||
const pool = await EnvSet.pool;
|
||||
const { value } = await pool.one<LogtoConfig>(sql`
|
||||
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
|
||||
where ${fields.tenantId} = ${adminTenantId}
|
||||
and ${fields.key} = ${LogtoOidcConfigKey.PrivateKeys}
|
||||
`);
|
||||
const privateKeys = logtoOidcConfigGuard['oidc.privateKeys']
|
||||
.parse(value)
|
||||
.map((key) => crypto.createPrivateKey(key));
|
||||
const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key));
|
||||
|
||||
return {
|
||||
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
|
||||
issuer: [
|
||||
(isDomainBasedMultiTenancy
|
||||
? urlSet.endpoint.replace('*', adminTenantId)
|
||||
: adminUrlSet.endpoint) + '/oidc',
|
||||
],
|
||||
};
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -10,6 +10,7 @@ const { mockEsmDefault } = createMockUtils(jest);
|
|||
|
||||
const mockProxyMiddleware = jest.fn();
|
||||
const mockStaticMiddleware = jest.fn();
|
||||
const mountedApps = Object.values(UserApps);
|
||||
|
||||
mockEsmDefault('fs/promises', () => ({
|
||||
readdir: jest.fn().mockResolvedValue(['sign-in']),
|
||||
|
@ -34,14 +35,14 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
const next = jest.fn();
|
||||
|
||||
for (const app of Object.values(MountedApps)) {
|
||||
for (const app of Object.values(mountedApps)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(`${app} path should not call dev proxy`, async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `/${app}/foo`,
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
|
||||
expect(mockProxyMiddleware).not.toBeCalled();
|
||||
});
|
||||
|
@ -49,7 +50,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
it('dev env should call dev proxy for SPA paths', async () => {
|
||||
const ctx = createContextWithRouteParameters();
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
expect(mockProxyMiddleware).toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -63,7 +64,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/foo',
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
expect(ctx.request.path).toEqual('/');
|
||||
|
@ -80,7 +81,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/sign-in',
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
stub.restore();
|
||||
});
|
||||
|
|
|
@ -5,10 +5,11 @@ import type { MiddlewareType } from 'koa';
|
|||
import proxy from 'koa-proxies';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import serveStatic from '#src/middleware/koa-serve-static.js';
|
||||
|
||||
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
mountedApps: string[],
|
||||
packagePath = 'ui',
|
||||
port = 5001,
|
||||
prefix = ''
|
||||
|
@ -38,10 +39,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
|||
const requestPath = ctx.request.path;
|
||||
|
||||
// Route has been handled by one of mounted apps
|
||||
if (
|
||||
!prefix &&
|
||||
Object.values(MountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
|
||||
) {
|
||||
if (!prefix && mountedApps.some((app) => app !== prefix && requestPath.startsWith(`/${app}`))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
import { MountedApps } from '#src/env-set/index.js';
|
||||
import { UserApps } from '#src/env-set/index.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -34,7 +34,7 @@ describe('koaSpaSessionGuard', () => {
|
|||
|
||||
const next = jest.fn();
|
||||
|
||||
for (const app of Object.values(MountedApps)) {
|
||||
for (const app of Object.values(UserApps)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(`${app} path should not redirect`, async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
|
|
|
@ -7,7 +7,7 @@ import type { AdapterFactory, AllClientMetadata } from 'oidc-provider';
|
|||
import { errors } from 'oidc-provider';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
|
@ -30,7 +30,7 @@ const buildDemoAppUris = (
|
|||
oidcClientMetadata: OidcClientMetadata
|
||||
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
|
||||
const { urlSet } = EnvSet.values;
|
||||
const urls = urlSet.deduplicated().map((url) => appendPath(url, MountedApps.DemoApp).toString());
|
||||
const urls = urlSet.deduplicated().map((url) => appendPath(url, UserApps.DemoApp).toString());
|
||||
|
||||
const data = {
|
||||
redirectUris: deduplicate([...urls, ...oidcClientMetadata.redirectUris]),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<LogtoConfig>(sql`
|
||||
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
|
||||
where ${fields.key} in (${sql.join(keys, sql`,`)})
|
||||
`);
|
||||
|
||||
return { getAdminConsoleConfig, updateAdminConsoleConfig, getRowsByKeys };
|
||||
};
|
||||
|
|
|
@ -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`, `)})`
|
||||
|
|
|
@ -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<readonly Scope[]> =>
|
||||
scopeIds.length > 0
|
||||
? pool.any<Scope>(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,
|
||||
|
|
71
packages/core/src/routes-me/init.ts
Normal file
71
packages/core/src/routes-me/init.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
adminTenantId,
|
||||
arbitraryObjectGuard,
|
||||
getManagementApiResourceIndicator,
|
||||
} from '@logto/schemas';
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
|
||||
import koaAuth from '#src/middleware/koa-auth/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
export default function initMeApis(tenant: TenantContext): Koa {
|
||||
if (tenant.id !== adminTenantId) {
|
||||
throw new Error('`/me` routes should only be initialized in the admin tenant.');
|
||||
}
|
||||
|
||||
const { findUserById, updateUserById } = tenant.queries.users;
|
||||
const meRouter = new Router<unknown, WithAuthContext>();
|
||||
|
||||
meRouter.use(
|
||||
koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me')),
|
||||
async (ctx, next) => {
|
||||
assertThat(
|
||||
ctx.auth.type === 'user',
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
meRouter.get('/custom-data', async (ctx, next) => {
|
||||
const { id: userId } = ctx.auth;
|
||||
const user = await findUserById(userId);
|
||||
|
||||
ctx.body = user.customData;
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
meRouter.patch(
|
||||
'/custom-data',
|
||||
koaGuard({
|
||||
body: arbitraryObjectGuard,
|
||||
response: arbitraryObjectGuard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id: userId } = ctx.auth;
|
||||
const { body: customData } = ctx.guard;
|
||||
|
||||
await findUserById(userId);
|
||||
|
||||
const user = await updateUserById(userId, {
|
||||
customData,
|
||||
});
|
||||
|
||||
ctx.body = user.customData;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
const meApp = new Koa();
|
||||
meApp.use(meRouter.routes()).use(meRouter.allowedMethods());
|
||||
|
||||
return meApp;
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { arbitraryObjectGuard, userInfoSelectFields, UserRole } from '@logto/schemas';
|
||||
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
|
||||
import { tryThat } from '@logto/shared';
|
||||
import { conditional, deduplicate, has, pick } from '@silverhand/essentials';
|
||||
import { conditional, has, pick } from '@silverhand/essentials';
|
||||
import { boolean, literal, object, string } from 'zod';
|
||||
|
||||
import { isTrue } from '#src/env-set/parameters.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
|
@ -19,7 +18,6 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
) {
|
||||
const {
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
roles: { findRolesByRoleNames },
|
||||
users: {
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
|
@ -44,13 +42,9 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
return tryThat(
|
||||
async () => {
|
||||
const search = parseSearchParamsForSearch(searchParams);
|
||||
const hideAdminUser = isTrue(searchParams.get('hideAdminUser'));
|
||||
const excludeRoleId = searchParams.get('excludeRoleId');
|
||||
const excludeUsersRoles = excludeRoleId ? await findUsersRolesByRoleId(excludeRoleId) : [];
|
||||
const excludeUserIdsByRole = excludeUsersRoles.map(({ userId }) => userId);
|
||||
const adminUsers = hideAdminUser ? await findUsersByRoleName(UserRole.Admin) : [];
|
||||
const excludeUserIdsByAdmin = adminUsers.map(({ id }) => id);
|
||||
const excludeUserIds = deduplicate([...excludeUserIdsByRole, ...excludeUserIdsByAdmin]);
|
||||
const excludeUserIds = excludeUsersRoles.map(({ userId }) => userId);
|
||||
|
||||
const [{ count }, users] = await Promise.all([
|
||||
countUsers(search, excludeUserIds),
|
||||
|
@ -140,12 +134,11 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
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 +170,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
name,
|
||||
...conditional(password && (await encryptUserPassword(password))),
|
||||
},
|
||||
isAdmin
|
||||
[]
|
||||
);
|
||||
|
||||
ctx.body = pick(user, ...userInfoSelectFields);
|
||||
|
|
|
@ -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<T extends AuthedRouter>(
|
|||
|
||||
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<T extends AuthedRouter>(
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { arbitraryObjectGuard } from '@logto/schemas';
|
|||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth.js';
|
||||
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import {
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { managementResourceScope } from '@logto/schemas';
|
||||
import cors from '@koa/cors';
|
||||
import { getManagementApiResourceIndicator } from '@logto/schemas';
|
||||
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';
|
||||
import koaAuth from '../middleware/koa-auth/index.js';
|
||||
import adminUserRoleRoutes from './admin-user-role.js';
|
||||
import adminUserRoutes from './admin-user.js';
|
||||
import applicationRoutes from './application.js';
|
||||
|
@ -32,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, getManagementApiResourceIndicator(tenant.id)));
|
||||
applicationRoutes(managementRouter, tenant);
|
||||
logtoConfigRoutes(managementRouter, tenant);
|
||||
connectorRoutes(managementRouter, tenant);
|
||||
|
@ -59,9 +61,22 @@ const createRouters = (tenant: TenantContext) => {
|
|||
return [interactionRouter, managementRouter, anonymousRouter];
|
||||
};
|
||||
|
||||
export default function initRouter(tenant: TenantContext): Koa {
|
||||
export default function initApis(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());
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InteractionEvent, adminConsoleApplicationId } from '@logto/schemas';
|
||||
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
|
@ -31,6 +31,10 @@ const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
mockEsm('#src/utils/tenant.js', () => ({
|
||||
getTenantId: () => adminTenantId,
|
||||
}));
|
||||
|
||||
const userQueries = {
|
||||
findUserById: jest
|
||||
.fn()
|
||||
|
@ -115,7 +119,7 @@ describe('submit action', () => {
|
|||
id: 'uid',
|
||||
...upsertProfile,
|
||||
},
|
||||
false
|
||||
[]
|
||||
);
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
|
||||
login: { accountId: 'uid' },
|
||||
|
@ -153,7 +157,7 @@ describe('submit action', () => {
|
|||
id: 'uid',
|
||||
...upsertProfile,
|
||||
},
|
||||
true
|
||||
['user', 'default:admin']
|
||||
);
|
||||
expect(assignInteractionResults).toBeCalledWith(adminConsoleCtx, tenant.provider, {
|
||||
login: { accountId: 'uid' },
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import type { User, Profile } from '@logto/schemas';
|
||||
import { InteractionEvent, adminConsoleApplicationId } from '@logto/schemas';
|
||||
import {
|
||||
UserRole,
|
||||
getManagementApiAdminName,
|
||||
defaultTenantId,
|
||||
adminTenantId,
|
||||
InteractionEvent,
|
||||
adminConsoleApplicationId,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
|
@ -7,6 +14,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 +165,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 ? [UserRole.User, getManagementApiAdminName(defaultTenantId)] : []
|
||||
);
|
||||
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
adminConsoleApplicationId,
|
||||
managementResourceId,
|
||||
managementResourceScope,
|
||||
defaultTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
PredefinedScope,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
|
@ -35,13 +36,13 @@ export default function consentRoutes<T>(
|
|||
|
||||
// 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 === PredefinedScope.All),
|
||||
new RequestError({ code: 'auth.forbidden', status: 401 })
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { ExtendableContext } from 'koa';
|
|||
import type Router from 'koa-router';
|
||||
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
||||
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
|
||||
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
|
|
|
@ -111,11 +111,12 @@ describe('GET /.well-known/sign-in-exp', () => {
|
|||
|
||||
expect(response.body).toMatchObject({
|
||||
...adminConsoleSignInExperience,
|
||||
tenantId: 'admin',
|
||||
branding: {
|
||||
...adminConsoleSignInExperience.branding,
|
||||
slogan: 'admin_console.welcome.title',
|
||||
},
|
||||
termsOfUseUrl: mockSignInExperience.termsOfUseUrl,
|
||||
termsOfUseUrl: null,
|
||||
languageInfo: mockSignInExperience.languageInfo,
|
||||
socialConnectors: [],
|
||||
signInMode: SignInMode.SignIn,
|
||||
|
|
|
@ -1,20 +1,31 @@
|
|||
import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { adminConsoleApplicationId } from '@logto/schemas';
|
||||
import { adminConsoleApplicationId, adminTenantId } 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';
|
||||
|
||||
export default function wellKnownRoutes<T extends AnonymousRouter>(
|
||||
...[router, { provider, libraries }]: RouterInitArgs<T>
|
||||
...[router, { provider, libraries, id }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
signInExperiences: { getSignInExperienceForApplication },
|
||||
connectors: { getLogtoConnectors },
|
||||
} = libraries;
|
||||
|
||||
if (id === adminTenantId) {
|
||||
router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => {
|
||||
ctx.body = {
|
||||
user: EnvSet.values.urlSet.endpoint.replace('*', ctx.params.tenantId ?? '*'),
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/.well-known/sign-in-exp',
|
||||
async (ctx, next) => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import Koa from 'koa';
|
||||
import compose from 'koa-compose';
|
||||
|
@ -5,7 +6,7 @@ import koaLogger from 'koa-logger';
|
|||
import mount from 'koa-mount';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
|
||||
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
|
||||
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
|
||||
|
@ -18,7 +19,8 @@ import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
|
|||
import type { ModelRouters } from '#src/model-routers/index.js';
|
||||
import { createModelRouters } from '#src/model-routers/index.js';
|
||||
import initOidc from '#src/oidc/init.js';
|
||||
import initRouter from '#src/routes/init.js';
|
||||
import initMeApis from '#src/routes-me/init.js';
|
||||
import initApis from '#src/routes/init.js';
|
||||
|
||||
import Libraries from './Libraries.js';
|
||||
import Queries from './Queries.js';
|
||||
|
@ -28,7 +30,7 @@ import { getTenantDatabaseDsn } from './utils.js';
|
|||
export default class Tenant implements TenantContext {
|
||||
static async create(id: string): Promise<Tenant> {
|
||||
// Treat the default database URL as the management URL
|
||||
const envSet = new EnvSet(await getTenantDatabaseDsn(id));
|
||||
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
|
||||
await envSet.load();
|
||||
|
||||
return new Tenant(envSet, id);
|
||||
|
@ -49,6 +51,11 @@ export default class Tenant implements TenantContext {
|
|||
const modelRouters = createModelRouters(envSet.queryClient);
|
||||
const queries = new Queries(envSet.pool);
|
||||
const libraries = new Libraries(queries, modelRouters);
|
||||
const isAdminTenant = id === adminTenantId;
|
||||
const mountedApps = [
|
||||
...Object.values(UserApps),
|
||||
...(isAdminTenant ? Object.values(AdminApps) : []),
|
||||
];
|
||||
|
||||
this.envSet = envSet;
|
||||
this.modelRouters = modelRouters;
|
||||
|
@ -68,29 +75,38 @@ export default class Tenant implements TenantContext {
|
|||
app.use(koaConnectorErrorHandler());
|
||||
app.use(koaI18next());
|
||||
|
||||
const tenantContext: TenantContext = { id, provider, queries, libraries, modelRouters, envSet };
|
||||
// Mount APIs
|
||||
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
|
||||
app.use(mount('/api', apisApp));
|
||||
app.use(mount('/api', initApis(tenantContext)));
|
||||
|
||||
// Mount Admin Console
|
||||
app.use(koaConsoleRedirectProxy(queries));
|
||||
app.use(
|
||||
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
|
||||
);
|
||||
// Mount admin tenant APIs and app
|
||||
if (id === adminTenantId) {
|
||||
// Mount `/me` APIs for admin tenant
|
||||
app.use(mount('/me', initMeApis(tenantContext)));
|
||||
|
||||
// Mount Admin Console
|
||||
app.use(koaConsoleRedirectProxy(queries));
|
||||
app.use(
|
||||
mount(
|
||||
'/' + AdminApps.Console,
|
||||
koaSpaProxy(mountedApps, AdminApps.Console, 5002, AdminApps.Console)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Mount demo app
|
||||
app.use(
|
||||
mount(
|
||||
'/' + MountedApps.DemoApp,
|
||||
'/' + UserApps.DemoApp,
|
||||
compose([
|
||||
koaCheckDemoApp(this.queries),
|
||||
koaSpaProxy(MountedApps.DemoApp, 5003, MountedApps.DemoApp),
|
||||
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Mount UI
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
|
||||
|
||||
this.app = app;
|
||||
this.provider = provider;
|
||||
|
|
|
@ -7,6 +7,7 @@ import type Libraries from './Libraries.js';
|
|||
import type Queries from './Queries.js';
|
||||
|
||||
export default abstract class TenantContext {
|
||||
public abstract readonly id: string;
|
||||
public abstract readonly envSet: EnvSet;
|
||||
public abstract readonly provider: Provider;
|
||||
public abstract readonly queries: Queries;
|
||||
|
|
|
@ -2,7 +2,7 @@ import LRUCache from 'lru-cache';
|
|||
|
||||
import Tenant from './Tenant.js';
|
||||
|
||||
class TenantPool {
|
||||
export class TenantPool {
|
||||
protected cache = new LRUCache<string, Tenant>({ max: 500 });
|
||||
|
||||
async get(tenantId: string): Promise<Tenant> {
|
||||
|
|
|
@ -57,7 +57,7 @@ export const checkRowLevelSecurity = async (client: QueryClient) => {
|
|||
`Found following table(s) without RLS: ${rlsDisabled
|
||||
.map((row) => conditionalString(isKeyInObject(row, 'tablename') && String(row.tablename)))
|
||||
.join(', ')}\n\n` +
|
||||
'Did you forget to run `npm cli db multi-tenancy enable`?'
|
||||
'Did you forget to run `npm cli db alteration deploy`?'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
export const mockEnvSet = new EnvSet(EnvSet.values.dbUrl);
|
||||
export const mockEnvSet = new EnvSet(EnvSet.values.endpoint, EnvSet.values.dbUrl);
|
||||
|
||||
await mockEnvSet.load();
|
||||
|
|
|
@ -45,6 +45,7 @@ export type DeepPartial<T> = T extends object
|
|||
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
|
||||
|
||||
export class MockTenant implements TenantContext {
|
||||
public id = 'mock_id';
|
||||
public envSet = mockEnvSet;
|
||||
public queries: Queries;
|
||||
public libraries: Libraries;
|
||||
|
|
33
packages/core/src/utils/tenant.ts
Normal file
33
packages/core/src/utils/tenant.ts
Normal file
|
@ -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];
|
||||
};
|
|
@ -5,7 +5,6 @@ export * from './sign-in-experience.js';
|
|||
export * from './admin-user.js';
|
||||
export * from './logs.js';
|
||||
export * from './dashboard.js';
|
||||
export * from './me.js';
|
||||
export * from './wellknown.js';
|
||||
export * from './interaction.js';
|
||||
|
||||
|
|
|
@ -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<UserInfo>();
|
||||
|
||||
export const getCurrentUserCustomData = (userId: string) =>
|
||||
api
|
||||
.get('me/custom-data', {
|
||||
headers: {
|
||||
'development-user-id': userId,
|
||||
},
|
||||
})
|
||||
.json<ArbitraryObject>();
|
||||
|
||||
export const updateCurrentUserCustomData = (userId: string, payload: Record<string, unknown>) =>
|
||||
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,
|
||||
},
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { adminRoleId } from '@logto/schemas';
|
||||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import { assignRolesToUser, getUserRoles, deleteRoleFromUser } from '#src/api/index.js';
|
||||
|
@ -25,8 +25,8 @@ describe('admin console user management (roles)', () => {
|
|||
it('should delete role from user successfully', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
await assignRolesToUser(user.id, [adminRoleId]);
|
||||
await deleteRoleFromUser(user.id, adminRoleId);
|
||||
await assignRolesToUser(user.id, [defaultManagementApi.role.id]);
|
||||
await deleteRoleFromUser(user.id, defaultManagementApi.role.id);
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
expect(roles.length).toBe(0);
|
||||
|
@ -35,7 +35,7 @@ describe('admin console user management (roles)', () => {
|
|||
it('should delete non-exist-role from user failed', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
const response = await deleteRoleFromUser(user.id, adminRoleId).catch(
|
||||
const response = await deleteRoleFromUser(user.id, defaultManagementApi.role.id).catch(
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true);
|
||||
|
|
|
@ -90,16 +90,6 @@ describe('admin console user search params', () => {
|
|||
json.length === 10 && json.every((user) => user.username?.startsWith('search_'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be able to hide admin users', async () => {
|
||||
const { headers, json } = await getUsers<User[]>([
|
||||
['search', '%search_tom%'],
|
||||
['hideAdminUser', 'true'],
|
||||
]);
|
||||
|
||||
expect(headers['total-number']).toEqual('2');
|
||||
expect(json.length === 2 && json.every((user) => user.name === 'Tom Scott')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to perform case sensitive exact search', async () => {
|
||||
|
@ -145,12 +135,11 @@ describe('admin console user search params', () => {
|
|||
['search.username', 'search_tom%'],
|
||||
['mode.username', 'similar_to'],
|
||||
['isCaseSensitive', 'true'],
|
||||
['hideAdminUser', 'true'],
|
||||
]);
|
||||
|
||||
expect(headers['total-number']).toEqual('2');
|
||||
expect(headers['total-number']).toEqual('5');
|
||||
expect(
|
||||
json.length === 2 && json.every((user) => user.username?.startsWith('search_'))
|
||||
json.length === 5 && json.every((user) => user.username?.startsWith('search_'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import path from 'path';
|
||||
|
||||
import { fetchTokenByRefreshToken } from '@logto/js';
|
||||
import {
|
||||
managementResource,
|
||||
InteractionEvent,
|
||||
adminRoleId,
|
||||
managementResourceScope,
|
||||
} from '@logto/schemas';
|
||||
import { defaultManagementApi, InteractionEvent } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
|
@ -27,14 +22,14 @@ describe('get access token', () => {
|
|||
beforeAll(async () => {
|
||||
await createUserByAdmin(guestUsername, password);
|
||||
const user = await createUserByAdmin(username, password);
|
||||
await assignUsersToRole([user.id], adminRoleId);
|
||||
await assignUsersToRole([user.id], defaultManagementApi.role.id);
|
||||
await enableAllPasswordSignInMethods();
|
||||
});
|
||||
|
||||
it('sign-in and getAccessToken with admin user', async () => {
|
||||
const client = new MockClient({
|
||||
resources: [managementResource.indicator],
|
||||
scopes: [managementResourceScope.name],
|
||||
resources: [defaultManagementApi.resource.indicator],
|
||||
scopes: [defaultManagementApi.scope.name],
|
||||
});
|
||||
await client.initSession();
|
||||
await client.successSend(putInteraction, {
|
||||
|
@ -43,11 +38,11 @@ describe('get access token', () => {
|
|||
});
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
const accessToken = await client.getAccessToken(managementResource.indicator);
|
||||
const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator);
|
||||
expect(accessToken).not.toBeNull();
|
||||
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
|
||||
'scope',
|
||||
managementResourceScope.name
|
||||
defaultManagementApi.scope.name
|
||||
);
|
||||
|
||||
// Request for invalid resource should throw
|
||||
|
@ -56,8 +51,8 @@ describe('get access token', () => {
|
|||
|
||||
it('sign-in and getAccessToken with guest user', async () => {
|
||||
const client = new MockClient({
|
||||
resources: [managementResource.indicator],
|
||||
scopes: [managementResourceScope.name],
|
||||
resources: [defaultManagementApi.resource.indicator],
|
||||
scopes: [defaultManagementApi.scope.name],
|
||||
});
|
||||
await client.initSession();
|
||||
await client.successSend(putInteraction, {
|
||||
|
@ -66,16 +61,16 @@ describe('get access token', () => {
|
|||
});
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
const accessToken = await client.getAccessToken(managementResource.indicator);
|
||||
const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator);
|
||||
|
||||
expect(getAccessTokenPayload(accessToken)).not.toHaveProperty(
|
||||
'scope',
|
||||
managementResourceScope.name
|
||||
defaultManagementApi.scope.name
|
||||
);
|
||||
});
|
||||
|
||||
it('sign-in and get multiple Access Token by the same Refresh Token within refreshTokenReuseInterval', async () => {
|
||||
const client = new MockClient({ resources: [managementResource.indicator] });
|
||||
const client = new MockClient({ resources: [defaultManagementApi.resource.indicator] });
|
||||
|
||||
await client.initSession();
|
||||
|
||||
|
@ -98,7 +93,7 @@ describe('get access token', () => {
|
|||
clientId: defaultConfig.appId,
|
||||
tokenEndpoint: path.join(logtoUrl, '/oidc/token'),
|
||||
refreshToken,
|
||||
resource: managementResource.indicator,
|
||||
resource: defaultManagementApi.resource.indicator,
|
||||
},
|
||||
async <T>(...args: Parameters<typeof fetch>): Promise<T> => {
|
||||
const response = await fetch(...args);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { managementResource, managementResourceScope } from '@logto/schemas';
|
||||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import { createResource } from '#src/api/index.js';
|
||||
|
@ -7,9 +7,9 @@ import { generateScopeName } from '#src/utils.js';
|
|||
|
||||
describe('scopes', () => {
|
||||
it('should get management api resource scopes successfully', async () => {
|
||||
const scopes = await getScopes(managementResource.id);
|
||||
const scopes = await getScopes(defaultManagementApi.resource.id);
|
||||
|
||||
expect(scopes[0]).toMatchObject(managementResourceScope);
|
||||
expect(scopes[0]).toMatchObject(defaultManagementApi.scope);
|
||||
});
|
||||
|
||||
it('should create scope successfully', async () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { managementResource } from '@logto/schemas';
|
||||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import { createResource, getResource, updateResource, deleteResource } from '#src/api/index.js';
|
||||
|
@ -6,9 +6,9 @@ import { generateResourceIndicator, generateResourceName } from '#src/utils.js';
|
|||
|
||||
describe('admin console api resources', () => {
|
||||
it('should get management api resource details successfully', async () => {
|
||||
const fetchedManagementApiResource = await getResource(managementResource.id);
|
||||
const fetchedManagementApiResource = await getResource(defaultManagementApi.resource.id);
|
||||
|
||||
expect(fetchedManagementApiResource).toMatchObject(managementResource);
|
||||
expect(fetchedManagementApiResource).toMatchObject(defaultManagementApi.resource);
|
||||
});
|
||||
|
||||
it('should create api resource successfully', async () => {
|
||||
|
|
212
packages/schemas/alterations/next-1676115897-add-admin-tenant.ts
Normal file
212
packages/schemas/alterations/next-1676115897-add-admin-tenant.ts
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
import { raw } from 'slonik-sql-tag-raw';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const adminTenantId = 'admin';
|
||||
|
||||
const getId = (value: string) => sql.identifier([value]);
|
||||
|
||||
const getDatabaseName = async (pool: CommonQueryMethods) => {
|
||||
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
|
||||
select current_database();
|
||||
`);
|
||||
|
||||
return currentDatabase.replaceAll('-', '_');
|
||||
};
|
||||
|
||||
const addManagementApiData = async (pool: CommonQueryMethods) => {
|
||||
const resourceId = generateStandardId();
|
||||
const roleId = generateStandardId();
|
||||
const scopeId = generateStandardId();
|
||||
|
||||
await pool.query(sql`
|
||||
insert into resources (tenant_id, id, indicator, name)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${resourceId},
|
||||
'https://default.logto.app/api',
|
||||
'Logto Management API for tenant default'
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into scopes (tenant_id, id, name, description, resource_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${scopeId},
|
||||
'all',
|
||||
'Default scope for Management API, allows all permissions.',
|
||||
${resourceId}
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into roles (tenant_id, id, name, description)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${roleId},
|
||||
'default:admin',
|
||||
'Admin role for Logto.'
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into roles_scopes (tenant_id, id, role_id, scope_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${roleId},
|
||||
${scopeId}
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
const addMeApiData = async (pool: CommonQueryMethods) => {
|
||||
const resourceId = generateStandardId();
|
||||
const roleId = generateStandardId();
|
||||
const scopeId = generateStandardId();
|
||||
|
||||
await pool.query(sql`
|
||||
insert into resources (tenant_id, id, indicator, name)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${resourceId},
|
||||
'https://admin.logto.app/me',
|
||||
'Logto Me API'
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into scopes (tenant_id, id, name, description, resource_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${scopeId},
|
||||
'all',
|
||||
'Default scope for Me API, allows all permissions.',
|
||||
${resourceId}
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into roles (tenant_id, id, name, description)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${roleId},
|
||||
'user',
|
||||
'Default role for admin tenant.'
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into roles_scopes (tenant_id, id, role_id, scope_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${roleId},
|
||||
${scopeId}
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
const database = await getDatabaseName(pool);
|
||||
|
||||
// Update function
|
||||
await pool.query(sql`
|
||||
create or replace function set_tenant_id() returns trigger as
|
||||
$$ begin
|
||||
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;
|
||||
`);
|
||||
|
||||
// Update users table constraint
|
||||
await pool.query(sql`
|
||||
alter table users
|
||||
drop constraint users_username_key,
|
||||
add constraint users__username unique (tenant_id, username),
|
||||
drop constraint users_primary_email_key,
|
||||
add constraint users__primary_email unique (tenant_id, primary_email),
|
||||
drop constraint users_primary_phone_key,
|
||||
add constraint users__primary_phone unique (tenant_id, primary_phone);
|
||||
`);
|
||||
|
||||
// Update old resource
|
||||
await pool.query(sql`
|
||||
update resources
|
||||
set indicator = 'https://default.logto.app/api'
|
||||
where indicator = 'https://api.logto.io';
|
||||
`);
|
||||
|
||||
// Create admin tenant
|
||||
const baseRole = `logto_tenant_${database}`;
|
||||
const role = `logto_tenant_${database}_${adminTenantId}`;
|
||||
const password = generateStandardId(32);
|
||||
|
||||
await pool.query(sql`
|
||||
insert into tenants (id, db_user, db_user_password)
|
||||
values (${adminTenantId}, ${role}, ${password});
|
||||
`);
|
||||
await pool.query(sql`
|
||||
create role ${getId(role)} with inherit login
|
||||
password '${raw(password)}'
|
||||
in role ${getId(baseRole)};
|
||||
`);
|
||||
|
||||
await addManagementApiData(pool);
|
||||
await addMeApiData(pool);
|
||||
},
|
||||
down: async (pool) => {
|
||||
const database = await getDatabaseName(pool);
|
||||
const role = `logto_tenant_${database}_${adminTenantId}`;
|
||||
|
||||
// Drop role and tenant
|
||||
await pool.query(sql`
|
||||
drop role ${getId(role)};
|
||||
`);
|
||||
await pool.query(sql`
|
||||
delete from tenants where id = ${adminTenantId};
|
||||
`);
|
||||
|
||||
// Restore users table constraint
|
||||
await pool.query(sql`
|
||||
alter table users
|
||||
drop constraint users__username,
|
||||
add constraint users_username_key unique (username),
|
||||
drop constraint users__primary_email,
|
||||
add constraint users_primary_email_key unique (primary_email),
|
||||
drop constraint users__primary_phone,
|
||||
add constraint users_primary_phone_key unique (primary_phone);
|
||||
`);
|
||||
|
||||
// Restore old resource
|
||||
await pool.query(sql`
|
||||
update resources
|
||||
set indicator = 'https://api.logto.io'
|
||||
where indicator = 'https://default.logto.app/api';
|
||||
`);
|
||||
|
||||
// Update function
|
||||
await pool.query(sql`
|
||||
create or replace 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';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end; $$ language plpgsql;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -0,0 +1,32 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
drop index logs__user_id;
|
||||
drop index logs__application_id;
|
||||
|
||||
create index logs__user_id
|
||||
on logs (tenant_id, (payload->>'userId'));
|
||||
|
||||
create index logs__application_id
|
||||
on logs (tenant_id, (payload->>'applicationId'));
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
drop index logs__user_id;
|
||||
drop index logs__application_id;
|
||||
|
||||
create index logs__user_id
|
||||
on logs (tenant_id, (payload->>'user_id') nulls last);
|
||||
|
||||
create index logs__application_id
|
||||
on logs (tenant_id, (payload->>'application_id') nulls last);
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -0,0 +1,208 @@
|
|||
import { generateKeyPair } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { generateStandardId } from '@logto/core-kit';
|
||||
import inquirer from 'inquirer';
|
||||
import type { CommonQueryMethods, SerializableValue } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
// Copied from CLI with default execution path
|
||||
const generateOidcPrivateKey = async () => {
|
||||
const { privateKey } = await promisify(generateKeyPair)('ec', {
|
||||
// https://security.stackexchange.com/questions/78621/which-elliptic-curve-should-i-use
|
||||
namedCurve: 'secp384r1',
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
|
||||
return privateKey;
|
||||
};
|
||||
|
||||
const generateOidcCookieKey = () => generateStandardId();
|
||||
|
||||
// Edited from CLI
|
||||
const updateConfigByKey = async <T>(
|
||||
pool: CommonQueryMethods,
|
||||
tenantId: string,
|
||||
key: string,
|
||||
value: SerializableValue
|
||||
) =>
|
||||
pool.query(
|
||||
sql`
|
||||
insert into logto_configs (tenant_id, key, value)
|
||||
values (${tenantId}, ${key}, ${sql.jsonb(value)})
|
||||
`
|
||||
);
|
||||
|
||||
const adminTenantId = 'admin';
|
||||
const defaultTenantId = 'default';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
// Init admin OIDC configs
|
||||
await updateConfigByKey(pool, adminTenantId, 'oidc.privateKeys', [
|
||||
await generateOidcPrivateKey(),
|
||||
]);
|
||||
await updateConfigByKey(pool, adminTenantId, 'oidc.cookieKeys', [generateOidcCookieKey()]);
|
||||
|
||||
// Skipped tables:
|
||||
// applications_roles, applications, connectors, custom_phrases, logto_configs,
|
||||
// passcodes, resources, roles_scopes, roles, scopes, sign_in_experiences,
|
||||
// systems, users_roles, hooks, tenants
|
||||
//
|
||||
// Migrate: logs, oidc_model_instances, users
|
||||
|
||||
// Find admin users
|
||||
const { rows } = await pool.query<{ userId: string; count: number }>(sql`
|
||||
select
|
||||
users.id as "userId",
|
||||
(select count(*) from users_roles where user_id = user_id)
|
||||
from users
|
||||
inner join users_roles on users.id = users_roles.user_id
|
||||
inner join roles on roles.id = users_roles.role_id
|
||||
where roles.name = 'admin';
|
||||
`);
|
||||
|
||||
const invalidUsers = rows.filter(({ count }) => count > 1);
|
||||
|
||||
if (invalidUsers.length > 0) {
|
||||
throw new Error(
|
||||
'Some of your current admin users has extra roles. Either remove their `admin` role to become a normal user, or remove all other roles to migrate them to the new Admin Tenant.\n\n' +
|
||||
'Invalid user IDs: ' +
|
||||
invalidUsers.map(({ userId }) => userId).join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
const userIds = rows.map(({ userId }) => userId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
console.log('No admin user found, skip alteration');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const inUserIds = sql`in (${sql.join(userIds, sql`, `)})`;
|
||||
|
||||
// Remove the admin role from users_roles
|
||||
await pool.query(sql`
|
||||
delete from users_roles
|
||||
where user_id ${inUserIds};
|
||||
`);
|
||||
|
||||
// Update data
|
||||
console.warn(
|
||||
'Some of the logs will stay in the default tenant since the related interaction has been removed.'
|
||||
);
|
||||
|
||||
await pool.query(sql`
|
||||
update users
|
||||
set tenant_id = ${adminTenantId}
|
||||
where id ${inUserIds};
|
||||
`);
|
||||
await pool.query(sql`
|
||||
update logs
|
||||
set tenant_id = ${adminTenantId}
|
||||
where payload->>'userId' ${inUserIds};
|
||||
`);
|
||||
await pool.query(sql`
|
||||
update oidc_model_instances
|
||||
set tenant_id = ${adminTenantId}
|
||||
where payload->>'accountId' ${inUserIds};
|
||||
`);
|
||||
|
||||
// Assign roles
|
||||
const { rows: roles } = await pool.query<{ id: string }>(sql`
|
||||
select id from roles
|
||||
where tenant_id = ${adminTenantId}
|
||||
and (name = ${'default:admin'} or name = ${'user'})
|
||||
`);
|
||||
|
||||
if (roles.length !== 2) {
|
||||
throw new Error('Admin tenant should have both `default:admin` and `user` role.');
|
||||
}
|
||||
|
||||
await pool.query(sql`
|
||||
insert into users_roles (tenant_id, id, user_id, role_id)
|
||||
values ${sql.join(
|
||||
userIds.flatMap((userId) =>
|
||||
roles.map(
|
||||
({ id }) => sql`(${adminTenantId}, ${generateStandardId()}, ${userId}, ${id})`
|
||||
)
|
||||
),
|
||||
sql`,`
|
||||
)};
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
const { rows } = await pool.query<{ id: string }>(sql`select id from tenants;`);
|
||||
const tenantIds = rows
|
||||
.map(({ id }) => id)
|
||||
.slice()
|
||||
.sort((i, j) => i.localeCompare(j));
|
||||
|
||||
if (!(tenantIds.length === 2 && tenantIds[0] === 'admin' && tenantIds[1] === 'default')) {
|
||||
throw new Error('The tenants table should only have exact `admin` and `default` tenant.');
|
||||
}
|
||||
|
||||
const isCi = process.env.CI;
|
||||
const { confirm } = await inquirer.prompt<{ confirm: boolean }>({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: String(
|
||||
'***CAUTION***\n' +
|
||||
'The `down()` function will restore Admin Tenant users to the default tenant.\n' +
|
||||
'Except `users`, and `logs`, ALL other data will be dropped.\n' +
|
||||
'Are you sure to continue?'
|
||||
),
|
||||
default: false,
|
||||
when: !isCi,
|
||||
});
|
||||
|
||||
if (!isCi && !confirm) {
|
||||
throw new Error('User cancelled alteration.');
|
||||
}
|
||||
|
||||
const { rows: adminUsers } = await pool.query<{ id: string }>(sql`
|
||||
select users.id from users
|
||||
inner join users_roles on users.id = users_roles.user_id
|
||||
inner join roles on roles.id = users_roles.role_id
|
||||
where roles.name = 'default:admin'
|
||||
and users.tenant_id = 'admin';
|
||||
`);
|
||||
const adminUserIds = adminUsers.map(({ id }) => id);
|
||||
|
||||
if (adminUserIds.length > 0) {
|
||||
await pool.query(sql`
|
||||
insert into users_roles (tenant_id, id, user_id, role_id)
|
||||
values ${sql.join(
|
||||
adminUserIds.map(
|
||||
(id) => sql`(${defaultTenantId}, ${generateStandardId()}, ${id}, ${'admin-role'})`
|
||||
),
|
||||
sql`,`
|
||||
)};
|
||||
`);
|
||||
|
||||
console.log(`Converted admin role for user ID(s): ${adminUserIds.join(', ')}`);
|
||||
}
|
||||
|
||||
await pool.query(sql`
|
||||
update users set tenant_id = ${defaultTenantId};
|
||||
`);
|
||||
await pool.query(sql`
|
||||
update logs set tenant_id = ${defaultTenantId};
|
||||
`);
|
||||
await pool.query(sql`
|
||||
delete from tenants where id = ${adminTenantId};
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -43,6 +43,7 @@
|
|||
"@silverhand/eslint-config": "1.3.0",
|
||||
"@silverhand/essentials": "2.1.0",
|
||||
"@silverhand/ts-config": "1.2.1",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/jest": "^29.1.2",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
106
packages/schemas/src/seeds/management-api.ts
Normal file
106
packages/schemas/src/seeds/management-api.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js';
|
||||
import { PredefinedScope, UserRole } from '../types/index.js';
|
||||
import { adminTenantId, defaultTenantId } from './tenant.js';
|
||||
|
||||
export type AdminData = {
|
||||
resource: CreateResource;
|
||||
scope: CreateScope;
|
||||
role: CreateRole;
|
||||
};
|
||||
|
||||
// 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://${defaultTenantId}.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: PredefinedScope.All,
|
||||
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, path = 'api') =>
|
||||
`https://${tenantId}.logto.app/${path}`;
|
||||
|
||||
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: PredefinedScope.All,
|
||||
description: 'Default scope for Management API, allows all permissions.',
|
||||
resourceId,
|
||||
},
|
||||
role: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: getManagementApiAdminName(tenantId),
|
||||
description: 'Admin role for Logto.',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createMeApiInAdminTenant = (): AdminData => {
|
||||
const resourceId = generateStandardId();
|
||||
|
||||
return Object.freeze({
|
||||
resource: {
|
||||
tenantId: adminTenantId,
|
||||
id: resourceId,
|
||||
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
|
||||
name: `Logto Me API`,
|
||||
},
|
||||
scope: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: PredefinedScope.All,
|
||||
description: 'Default scope for Me API, allows all permissions.',
|
||||
resourceId,
|
||||
},
|
||||
role: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: UserRole.User,
|
||||
description: 'Default role for admin tenant.',
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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<CreateResource> = 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',
|
||||
});
|
|
@ -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<CreateRole> = {
|
||||
tenantId: defaultTenantId,
|
||||
id: adminRoleId,
|
||||
name: UserRole.Admin,
|
||||
description: 'Admin role for Logto.',
|
||||
};
|
||||
|
||||
export const defaultRoleScopeRelation: Readonly<CreateRolesScope> = {
|
||||
id: adminRoleScopeId,
|
||||
tenantId: defaultTenantId,
|
||||
roleId: adminRoleId,
|
||||
scopeId: managementApiScopeAll,
|
||||
};
|
|
@ -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<CreateScope> = Object.freeze({
|
||||
tenantId: defaultTenantId,
|
||||
id: managementApiScopeAll,
|
||||
name: 'all',
|
||||
description: 'Default scope for Management API, allows all permissions.',
|
||||
resourceId: managementResourceId,
|
||||
});
|
|
@ -24,4 +24,10 @@ export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean };
|
|||
|
||||
export enum UserRole {
|
||||
Admin = 'admin',
|
||||
/** Common user role in admin tenant. */
|
||||
User = 'user',
|
||||
}
|
||||
|
||||
export enum PredefinedScope {
|
||||
All = 'all',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ create index logs__key
|
|||
on logs (tenant_id, key);
|
||||
|
||||
create index logs__user_id
|
||||
on logs (tenant_id, (payload->>'user_id') nulls last);
|
||||
on logs (tenant_id, (payload->>'userId'));
|
||||
|
||||
create index logs__application_id
|
||||
on logs (tenant_id, (payload->>'application_id') nulls last);
|
||||
on logs (tenant_id, (payload->>'applicationId'));
|
||||
|
|
|
@ -7,7 +7,7 @@ create table passcodes (
|
|||
email varchar(128),
|
||||
type varchar(32) not null,
|
||||
code varchar(6) not null,
|
||||
consumed boolean not null default FALSE,
|
||||
consumed boolean not null default false,
|
||||
try_count int2 not null default 0,
|
||||
created_at timestamptz not null default(now()),
|
||||
primary key (id)
|
||||
|
|
|
@ -6,9 +6,9 @@ create table users (
|
|||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
id varchar(12) not null,
|
||||
username varchar(128) unique,
|
||||
primary_email varchar(128) unique,
|
||||
primary_phone varchar(128) unique,
|
||||
username varchar(128),
|
||||
primary_email varchar(128),
|
||||
primary_phone varchar(128),
|
||||
password_encrypted varchar(128),
|
||||
password_encryption_method users_password_encryption_method,
|
||||
name varchar(128),
|
||||
|
@ -19,7 +19,13 @@ create table users (
|
|||
is_suspended boolean not null default false,
|
||||
last_sign_in_at timestamptz,
|
||||
created_at timestamptz not null default (now()),
|
||||
primary key (id)
|
||||
primary key (id),
|
||||
constraint users__username
|
||||
unique (tenant_id, username),
|
||||
constraint users__primary_email
|
||||
unique (tenant_id, primary_email),
|
||||
constraint users__primary_phone
|
||||
unique (tenant_id, primary_phone)
|
||||
);
|
||||
|
||||
create index users__id
|
||||
|
|
|
@ -60,8 +60,6 @@ const App = () => {
|
|||
<AppBoundary>
|
||||
<Routes>
|
||||
<Route element={<AppContent />}>
|
||||
<Route index element={<Navigate replace to="/sign-in" />} />
|
||||
|
||||
<Route
|
||||
path="unknown-session"
|
||||
element={<ErrorPage message="error.invalid_session" />}
|
||||
|
|
|
@ -29,9 +29,10 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
|
|||
document.body.classList.add(conditionalString(styles.preview));
|
||||
|
||||
const previewMessageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
// TODO: @simeng: we can check allowed origins via `/.well-known/endpoints`
|
||||
// if (event.origin !== window.location.origin) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (event.data.sender === 'ac_preview') {
|
||||
// #event.data should be guarded at the provider's side
|
||||
|
|
|
@ -254,6 +254,7 @@ importers:
|
|||
|
||||
packages/core:
|
||||
specifiers:
|
||||
'@koa/cors': ^4.0.0
|
||||
'@logto/cli': workspace:*
|
||||
'@logto/connector-kit': workspace:*
|
||||
'@logto/core-kit': workspace:*
|
||||
|
@ -275,6 +276,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
|
||||
|
@ -330,6 +332,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
|
||||
|
@ -388,6 +391,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
|
||||
|
@ -590,6 +594,7 @@ importers:
|
|||
'@silverhand/eslint-config': 1.3.0
|
||||
'@silverhand/essentials': 2.1.0
|
||||
'@silverhand/ts-config': 1.2.1
|
||||
'@types/inquirer': ^9.0.0
|
||||
'@types/jest': ^29.1.2
|
||||
'@types/node': ^18.11.18
|
||||
'@types/pluralize': ^0.0.29
|
||||
|
@ -617,6 +622,7 @@ importers:
|
|||
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
|
||||
'@silverhand/essentials': 2.1.0
|
||||
'@silverhand/ts-config': 1.2.1_typescript@4.9.4
|
||||
'@types/inquirer': 9.0.3
|
||||
'@types/jest': 29.1.2
|
||||
'@types/node': 18.11.18
|
||||
'@types/pluralize': 0.0.29
|
||||
|
@ -4008,7 +4014,7 @@ packages:
|
|||
resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==}
|
||||
dependencies:
|
||||
'@types/through': 0.0.30
|
||||
rxjs: 7.5.5
|
||||
rxjs: 7.8.0
|
||||
dev: true
|
||||
|
||||
/@types/is-ci/3.0.0:
|
||||
|
@ -4109,6 +4115,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:
|
||||
|
@ -12927,12 +12939,6 @@ packages:
|
|||
queue-microtask: 1.2.3
|
||||
dev: true
|
||||
|
||||
/rxjs/7.5.5:
|
||||
resolution: {integrity: sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==}
|
||||
dependencies:
|
||||
tslib: 2.4.1
|
||||
dev: true
|
||||
|
||||
/rxjs/7.8.0:
|
||||
resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue