0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor!: add admin tenant

This commit is contained in:
Gao Sun 2023-02-10 13:06:52 +08:00
parent 1d7f22debf
commit 2af6fd114a
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
42 changed files with 496 additions and 337 deletions

View file

@ -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`);

View file

@ -1,68 +1,14 @@
import { logtoConfigGuards, LogtoOidcConfigKey } from '@logto/schemas';
import chalk from 'chalk';
import type { DatabasePool, DatabaseTransactionConnection } from 'slonik';
import type { DatabasePool } from 'slonik';
import type { CommandModule } from 'yargs';
import { z } from 'zod';
import { createPoolAndDatabaseIfNeeded } from '../../../database.js';
import {
getRowsByKeys,
doesConfigsTableExist,
updateValueByKey,
} from '../../../queries/logto-config.js';
import { doesConfigsTableExist } from '../../../queries/logto-config.js';
import { log, oraPromise } from '../../../utilities.js';
import { getLatestAlterationTimestamp } from '../alteration/index.js';
import { getAlterationDirectory } from '../alteration/utils.js';
import { oidcConfigReaders } from './oidc-config.js';
import { createTables, seedTables } from './tables.js';
const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => {
const configGuard = z.object({
key: z.nativeEnum(LogtoOidcConfigKey),
value: z.unknown(),
});
const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey));
// Filter out valid keys that hold a valid value
const result = await Promise.all(
rows.map<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);
});
};

View file

@ -1,13 +1,66 @@
import { readFile } from 'fs/promises';
import type { LogtoOidcConfigType } from '@logto/schemas';
import { LogtoOidcConfigKey } from '@logto/schemas';
import { LogtoOidcConfigKey, logtoConfigGuards } from '@logto/schemas';
import { getEnvAsStringArray } from '@silverhand/essentials';
import chalk from 'chalk';
import type { DatabaseTransactionConnection } from 'slonik';
import { z } from 'zod';
import { getRowsByKeys, updateValueByKey } from '../../../queries/logto-config.js';
import { log } from '../../../utilities.js';
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js';
const isBase64FormatPrivateKey = (key: string) => !key.includes('-');
export const seedOidcConfigs = async (pool: DatabaseTransactionConnection, tenantId: string) => {
const tenantPrefix = `[${tenantId}]`;
const configGuard = z.object({
key: z.nativeEnum(LogtoOidcConfigKey),
value: z.unknown(),
});
const { rows } = await getRowsByKeys(pool, tenantId, Object.values(LogtoOidcConfigKey));
// Filter out valid keys that hold a valid value
const result = await Promise.all(
rows.map<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)

View file

@ -3,14 +3,13 @@ import path from 'path';
import { generateStandardId } from '@logto/core-kit';
import {
managementResource,
defaultSignInExperience,
createDefaultAdminConsoleConfig,
createDemoAppApplication,
defaultRole,
managementResourceScope,
defaultRoleScopeRelation,
defaultTenantId,
adminTenantId,
defaultManagementApi,
createManagementApiInAdminTenant,
} from '@logto/schemas';
import { Hooks, Tenants } from '@logto/schemas/models';
import type { DatabaseTransactionConnection } from 'slonik';
@ -21,7 +20,8 @@ import { insertInto } from '../../../database.js';
import { getDatabaseName } from '../../../queries/database.js';
import { updateDatabaseTimestamp } from '../../../queries/system.js';
import { getPathInModule } from '../../../utilities.js';
import { createTenant } from './tenant.js';
import { seedOidcConfigs } from './oidc-config.js';
import { createTenant, seedAdminData } from './tenant.js';
const getExplicitOrder = (query: string) => {
const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1];
@ -114,14 +114,17 @@ export const seedTables = async (
latestTimestamp: number
) => {
await createTenant(connection, defaultTenantId);
await seedOidcConfigs(connection, defaultTenantId);
await seedAdminData(connection, defaultManagementApi);
await createTenant(connection, adminTenantId);
await seedOidcConfigs(connection, adminTenantId);
await seedAdminData(connection, createManagementApiInAdminTenant(defaultTenantId));
await Promise.all([
connection.query(insertInto(managementResource, 'resources')),
connection.query(insertInto(managementResourceScope, 'scopes')),
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')),
connection.query(insertInto(defaultRole, 'roles')),
connection.query(insertInto(defaultRoleScopeRelation, 'roles_scopes')),
updateDatabaseTimestamp(connection, latestTimestamp),
]);
};

View file

@ -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'
)
);
};

View file

@ -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}
`

View file

@ -1,10 +1,7 @@
import { UserScope } from '@logto/core-kit';
import { LogtoProvider } from '@logto/react';
import {
adminConsoleApplicationId,
managementResource,
managementResourceScope,
} from '@logto/schemas';
import { adminConsoleApplicationId } from '@logto/schemas';
import { useContext } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { SWRConfig } from 'swr';
@ -14,7 +11,9 @@ import './scss/overlayscrollbars.scss';
// eslint-disable-next-line import/no-unassigned-import
import '@fontsource/roboto-mono';
import AppLoading from '@/components/AppLoading';
import Toast from '@/components/Toast';
import { managementApi } from '@/consts/management-api';
import AppBoundary from '@/containers/AppBoundary';
import AppLayout from '@/containers/AppLayout';
import ErrorBoundary from '@/containers/ErrorBoundary';
@ -48,6 +47,7 @@ import {
UserDetailsTabs,
} from './consts/page-tabs';
import AppContent from './containers/AppContent';
import AppEndpointProvider, { AppEndpointContext } from './containers/AppEndpointProvider';
import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions';
import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings';
import CloudPreview from './pages/CloudPreview';
@ -63,6 +63,11 @@ void initI18n();
const Main = () => {
const swrOptions = useSwrOptions();
const { endpoint } = useContext(AppEndpointContext);
if (!endpoint) {
return <AppLoading />;
}
return (
<ErrorBoundary>
@ -154,16 +159,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>
<AppEndpointProvider>
<LogtoProvider
config={{
endpoint: window.location.origin,
appId: adminConsoleApplicationId,
resources: [managementApi.indicator],
scopes: [UserScope.Identities, UserScope.CustomData, managementApi.scopeAll],
}}
>
<Main />
</LogtoProvider>
</AppEndpointProvider>
</BrowserRouter>
);

View file

@ -1,6 +1,7 @@
export * from './applications';
export * from './connectors';
export * from './logs';
export * from './management-api';
export const themeStorageKey = 'logto:admin_console:theme';
export const requestTimeout = 20_000;

View file

@ -0,0 +1,10 @@
import {
defaultTenantId,
getManagementApiResourceIndicator,
managementApiScopeAll,
} from '@logto/schemas';
export const managementApi = Object.freeze({
indicator: getManagementApiResourceIndicator(defaultTenantId),
scopeAll: managementApiScopeAll,
});

View file

@ -0,0 +1,31 @@
import ky from 'ky';
import type { ReactNode } from 'react';
import { useMemo, useEffect, createContext, useState } from 'react';
type Props = {
children: ReactNode;
};
export const AppEndpointContext = createContext<{ endpoint?: URL }>({});
const AppEndpointProvider = ({ children }: Props) => {
const [endpoint, setEndpoint] = useState<URL>();
const memorizedContext = useMemo(() => ({ endpoint }), [endpoint]);
useEffect(() => {
const getEndpoint = async () => {
const { app } = await ky
.get(new URL('api/.well-known/endpoints', window.location.origin))
.json<{ app: string }>();
setEndpoint(new URL(app));
};
void getEndpoint();
}, []);
return (
<AppEndpointContext.Provider value={memorizedContext}>{children}</AppEndpointContext.Provider>
);
};
export default AppEndpointProvider;

View file

@ -1,12 +1,12 @@
import { useLogto } from '@logto/react';
import type { RequestErrorBody } from '@logto/schemas';
import { managementResource } from '@logto/schemas';
import ky from 'ky';
import { useCallback, useMemo } from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { requestTimeout } from '@/consts';
import { managementApi, requestTimeout } from '@/consts';
import { AppEndpointContext } from '@/containers/AppEndpointProvider';
export class RequestError extends Error {
status: number;
@ -24,6 +24,7 @@ type Props = {
};
const useApi = ({ hideErrorToast }: Props = {}) => {
const { endpoint } = useContext(AppEndpointContext);
const { isAuthenticated, getAccessToken } = useLogto();
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -44,7 +45,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
const api = useMemo(
() =>
ky.create({
prefixUrl: window.location.origin,
prefixUrl: endpoint,
timeout: requestTimeout,
hooks: {
beforeError: hideErrorToast
@ -59,7 +60,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
beforeRequest: [
async (request) => {
if (isAuthenticated) {
const accessToken = await getAccessToken(managementResource.indicator);
const accessToken = await getAccessToken(managementApi.indicator);
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
request.headers.set('Accept-Language', i18n.language);
}
@ -67,7 +68,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
],
},
}),
[hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language]
[endpoint, hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language]
);
return api;

View file

@ -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);

View file

@ -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",

View file

@ -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;

View file

@ -4,6 +4,7 @@ import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik';
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
import { appendPath } from '#src/utils/url.js';
import GlobalValues from './GlobalValues.js';
@ -82,7 +83,7 @@ export class EnvSet {
this.#pool = pool;
this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest);
const { getOidcConfigs } = createLogtoConfigLibrary(pool);
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
const oidcConfigs = await getOidcConfigs();
this.#oidc = await loadOidcValues(

View file

@ -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,
};
};

View file

@ -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)

View file

@ -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;

View file

@ -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]);
});
});

View file

@ -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,
};
};

View file

@ -1,4 +1,4 @@
import { managementResourceScope, UserRole } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
@ -17,7 +17,7 @@ const { mockEsm } = createMockUtils(jest);
const { jwtVerify } = mockEsm('jose', () => ({
jwtVerify: jest
.fn()
.mockReturnValue({ payload: { sub: 'fooUser', scope: managementResourceScope.name } }),
.mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }),
}));
const koaAuth = await pickDefault(import('./koa-auth.js'));
@ -181,9 +181,7 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
forbiddenError
);
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError);
});
it('expect to throw if jwt scope does not include management resource scope', async () => {
@ -198,9 +196,7 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet, UserRole.Admin)(ctx, next)).rejects.toMatchError(
forbiddenError
);
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError);
});
it('expect to throw unauthorized error if unknown error occurs', async () => {

View file

@ -1,6 +1,6 @@
import type { IncomingHttpHeaders } from 'http';
import { managementResource, managementResourceScope } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { jwtVerify } from 'jose';
import type { MiddlewareType, Request } from 'koa';
@ -54,7 +54,9 @@ export const verifyBearerTokenFromRequest = async (
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
if ((!isProduction || isIntegrationTest) && userId) {
return { sub: userId, clientId: undefined, scopes: [managementResourceScope.name] };
console.log(`Found dev user ID ${userId}, skip token validation.`);
return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] };
}
try {
@ -79,22 +81,19 @@ export const verifyBearerTokenFromRequest = async (
};
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
envSet: EnvSet,
forScope?: string
envSet: EnvSet
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
envSet,
ctx.request,
managementResource.indicator
defaultManagementApi.resource.indicator
);
if (forScope) {
assertThat(
scopes.includes(forScope),
new RequestError({ code: 'auth.forbidden', status: 403 })
);
}
assertThat(
scopes.includes(defaultManagementApi.scope.name),
new RequestError({ code: 'auth.forbidden', status: 403 })
);
ctx.auth = {
type: sub === clientId ? 'app' : 'user',

View file

@ -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,

View file

@ -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 };
};

View file

@ -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`, `)})`

View file

@ -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,

View file

@ -140,12 +140,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 +176,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(
name,
...conditional(password && (await encryptUserPassword(password))),
},
isAdmin
[]
);
ctx.body = pick(user, ...userInfoSelectFields);

View file

@ -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);
}
}

View file

@ -1,7 +1,8 @@
import { managementResourceScope } from '@logto/schemas';
import cors from '@koa/cors';
import Koa from 'koa';
import Router from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import koaAuth from '../middleware/koa-auth.js';
@ -33,7 +34,7 @@ const createRouters = (tenant: TenantContext) => {
interactionRoutes(interactionRouter, tenant);
const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, managementResourceScope.name));
managementRouter.use(koaAuth(tenant.envSet));
applicationRoutes(managementRouter, tenant);
logtoConfigRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant);
@ -64,6 +65,19 @@ const createRouters = (tenant: TenantContext) => {
export default function initRouter(tenant: TenantContext): Koa {
const apisApp = new Koa();
apisApp.use(
cors({
origin: (ctx) => {
const { origin } = ctx.request.headers;
return origin &&
EnvSet.values.adminUrlSet.deduplicated().some((value) => new URL(value).origin === origin)
? origin
: '';
},
})
);
for (const router of createRouters(tenant)) {
apisApp.use(router.routes()).use(router.allowedMethods());
}

View file

@ -1,5 +1,11 @@
import type { User, Profile } from '@logto/schemas';
import { InteractionEvent, adminConsoleApplicationId } from '@logto/schemas';
import {
getManagementApiAdminName,
defaultTenantId,
adminTenantId,
InteractionEvent,
adminConsoleApplicationId,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
@ -7,6 +13,7 @@ import { assignInteractionResults } from '#src/libraries/session.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import type { LogEntry } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { getTenantId } from '#src/utils/tenant.js';
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
import type {
@ -157,14 +164,16 @@ export default async function submitInteraction(
const { client_id } = ctx.interactionDetails.params;
const createAdminUser =
String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers());
getTenantId(ctx.URL) === adminTenantId &&
String(client_id) === adminConsoleApplicationId &&
!(await hasActiveUsers());
await insertUser(
{
id,
...upsertProfile,
},
createAdminUser
createAdminUser ? [getManagementApiAdminName(defaultTenantId)] : []
);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });

View file

@ -1,7 +1,8 @@
import {
adminConsoleApplicationId,
managementResourceId,
managementResourceScope,
defaultTenantId,
getManagementApiResourceIndicator,
managementApiScopeAll,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router';
@ -35,13 +36,13 @@ export default function consentRoutes<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 === managementApiScopeAll),
new RequestError({ code: 'auth.forbidden', status: 401 })
);
}

View file

@ -3,6 +3,7 @@ import { ConnectorType } from '@logto/connector-kit';
import { adminConsoleApplicationId } from '@logto/schemas';
import etag from 'etag';
import { EnvSet } from '#src/env-set/index.js';
import { getApplicationIdFromInteraction } from '#src/libraries/session.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';
@ -15,6 +16,15 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
connectors: { getLogtoConnectors },
} = libraries;
router.get('/.well-known/endpoints', async (ctx, next) => {
ctx.body = {
console: EnvSet.values.adminUrlSet.endpoint,
app: EnvSet.values.urlSet.endpoint,
};
return next();
});
router.get(
'/.well-known/sign-in-exp',
async (ctx, next) => {

View 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];
};

View file

@ -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,
},
});

View file

@ -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';

View file

@ -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',

View file

@ -0,0 +1,82 @@
import { generateStandardId } from '@logto/core-kit';
import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js';
import { UserRole } from '../types/index.js';
import { adminTenantId, defaultTenantId } from './tenant.js';
export type AdminData = {
resource: CreateResource;
scope: CreateScope;
role: CreateRole;
};
export const managementApiScopeAll = 'all';
// Consider remove the dependency of IDs
const defaultResourceId = 'management-api';
const defaultScopeAllId = 'management-api-all';
// Consider combine this with `createManagementApiInAdminTenant()`
/** The fixed Management API Resource for `default` tenant. */
export const defaultManagementApi = Object.freeze({
resource: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: defaultResourceId,
/**
* The fixed resource indicator for Management APIs.
*
* Admin Console requires the access token of this resource to be functional.
*/
indicator: 'https://logto.app/api',
name: 'Logto Management API',
},
scope: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: defaultScopeAllId,
name: managementApiScopeAll,
description: 'Default scope for Management API, allows all permissions.',
/** @deprecated You should not rely on this constant. Change to something else. */
resourceId: defaultResourceId,
},
role: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: 'admin-role',
name: UserRole.Admin,
description: 'Admin role for Logto.',
},
}) satisfies AdminData;
export const getManagementApiResourceIndicator = (tenantId: string) =>
`https://${tenantId}.logto.app/api`;
export const getManagementApiAdminName = (tenantId: string) => `${tenantId}:${UserRole.Admin}`;
/** Create a Management API Resource of the given tenant ID for `admin` tenant. */
export const createManagementApiInAdminTenant = (tenantId: string): AdminData => {
const resourceId = generateStandardId();
return Object.freeze({
resource: {
tenantId: adminTenantId,
id: resourceId,
indicator: getManagementApiResourceIndicator(tenantId),
name: `Logto Management API for tenant ${tenantId}`,
},
scope: {
tenantId: adminTenantId,
id: generateStandardId(),
name: managementApiScopeAll,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
role: {
tenantId: adminTenantId,
id: generateStandardId(),
name: getManagementApiAdminName(tenantId),
description: 'Admin role for Logto.',
},
});
};

View file

@ -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',
});

View file

@ -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,
};

View file

@ -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,
});

View file

@ -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;

10
pnpm-lock.yaml generated
View file

@ -250,6 +250,7 @@ importers:
packages/core:
specifiers:
'@koa/cors': ^4.0.0
'@logto/cli': workspace:*
'@logto/connector-kit': workspace:*
'@logto/core-kit': workspace:*
@ -271,6 +272,7 @@ importers:
'@types/koa-logger': ^3.1.1
'@types/koa-mount': ^4.0.0
'@types/koa-send': ^4.1.3
'@types/koa__cors': ^3.3.0
'@types/node': ^18.11.18
'@types/oidc-provider': ^8.0.0
'@types/semver': ^7.3.12
@ -326,6 +328,7 @@ importers:
typescript: ^4.9.4
zod: ^3.20.2
dependencies:
'@koa/cors': 4.0.0
'@logto/cli': link:../cli
'@logto/connector-kit': link:../toolkit/connector-kit
'@logto/core-kit': link:../toolkit/core-kit
@ -384,6 +387,7 @@ importers:
'@types/koa-logger': 3.1.2
'@types/koa-mount': 4.0.1
'@types/koa-send': 4.1.3
'@types/koa__cors': 3.3.0
'@types/node': 18.11.18
'@types/oidc-provider': 8.0.0
'@types/semver': 7.3.12
@ -4099,6 +4103,12 @@ packages:
'@types/node': 18.11.18
dev: true
/@types/koa__cors/3.3.0:
resolution: {integrity: sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA==}
dependencies:
'@types/koa': 2.13.4
dev: true
/@types/mdast/3.0.10:
resolution: {integrity: sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==}
dependencies: