mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge pull request #3274 from logto-io/gao-seed-for-multi-tenancy
refactor: seed data for multi-tenancy
This commit is contained in:
commit
0a3d4bb345
36 changed files with 383 additions and 114 deletions
15
.changeset-staged/lucky-shrimps-collect.md
Normal file
15
.changeset-staged/lucky-shrimps-collect.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
"@logto/cli": major
|
||||
"@logto/cloud": patch
|
||||
"@logto/console": patch
|
||||
"@logto/core": patch
|
||||
"@logto/schemas": patch
|
||||
"@logto/shared": patch
|
||||
---
|
||||
|
||||
**Seed data for cloud**
|
||||
|
||||
- cli!: remove `oidc` option for `database seed` command as it's unused
|
||||
- cli: add hidden `--cloud` option for `database seed` command to init cloud data
|
||||
- cli, cloud: appending Redirect URIs to Admin Console will deduplicate values before update
|
||||
- move `UrlSet` and `GlobalValues` to `@logto/shared`
|
|
@ -114,6 +114,19 @@ const manifests = [
|
|||
|
||||
assert.deepStrictEqual(...manifests);
|
||||
|
||||
const autoCompare = (a, b) => {
|
||||
if (typeof a !== typeof b) {
|
||||
return (typeof a).localeCompare(typeof b);
|
||||
}
|
||||
|
||||
return String(a).localeCompare(String(b));
|
||||
};
|
||||
|
||||
const buildSortByKeys = (keys) => (a, b) => {
|
||||
const found = keys.find((key) => a[key] !== b[key]);
|
||||
return found ? autoCompare(a[found], b[found]) : 0;
|
||||
};
|
||||
|
||||
const queryDatabaseData = async (database) => {
|
||||
const pool = new pg.Pool({ database, user: 'postgres', password: 'postgres' });
|
||||
const result = await Promise.all(manifests[0].tables
|
||||
|
@ -122,16 +135,11 @@ const queryDatabaseData = async (database) => {
|
|||
|
||||
// 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);
|
||||
})];
|
||||
const data = omitArray(rows, 'value');
|
||||
return [table_name, data.sort(buildSortByKeys(Object.keys(data[0] ?? {})))];
|
||||
}
|
||||
|
||||
return [table_name, omitArray(
|
||||
const data = omitArray(
|
||||
rows,
|
||||
'id',
|
||||
'resource_id',
|
||||
|
@ -142,7 +150,9 @@ const queryDatabaseData = async (database) => {
|
|||
'secret',
|
||||
'db_user',
|
||||
'db_user_password'
|
||||
)];
|
||||
);
|
||||
|
||||
return [table_name, data.sort(buildSortByKeys(Object.keys(data[0] ?? {})))];
|
||||
})
|
||||
);
|
||||
|
||||
|
|
39
packages/cli/src/commands/database/seed/cloud.ts
Normal file
39
packages/cli/src/commands/database/seed/cloud.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { adminConsoleApplicationId, adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import { appendPath, GlobalValues } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { log } from '../../../utils.js';
|
||||
|
||||
/**
|
||||
* Append Redirect URIs for the default tenant callback in cloud Admin Console.
|
||||
* It reads the same env variables as core to construct the cloud `UrlSet`.
|
||||
*
|
||||
* E.g., by default, it will appends `http://localhost:3003/default/callback` to the Redirect URIs.
|
||||
*
|
||||
* For why it is necessary, see the redirect lifecycle of cloud Admin Console.
|
||||
*/
|
||||
export const appendAdminConsoleRedirectUris = async (pool: CommonQueryMethods) => {
|
||||
const redirectUris = new GlobalValues().cloudUrlSet
|
||||
.deduplicated()
|
||||
.map((endpoint) => appendPath(endpoint, defaultTenantId, 'callback'));
|
||||
|
||||
const metadataKey = sql.identifier(['oidc_client_metadata']);
|
||||
|
||||
// Copied from packages/cloud/src/queries/tenants.ts
|
||||
// Can be merged into the original once we remove slonik
|
||||
await pool.query(sql`
|
||||
update applications
|
||||
set ${metadataKey} = jsonb_set(
|
||||
${metadataKey},
|
||||
'{redirectUris}',
|
||||
(select jsonb_agg(distinct value) from jsonb_array_elements(
|
||||
${metadataKey}->'redirectUris' || ${sql.jsonb(redirectUris.map(String))}
|
||||
))
|
||||
)
|
||||
where id = ${adminConsoleApplicationId}
|
||||
and tenant_id = ${adminTenantId}
|
||||
`);
|
||||
|
||||
log.succeed('Appended initial Redirect URIs to Admin Console:', redirectUris.map(String));
|
||||
};
|
|
@ -7,38 +7,33 @@ 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 { createTables, seedTables } from './tables.js';
|
||||
import { createTables, seedCloud, seedTables } from './tables.js';
|
||||
|
||||
const seedChoices = Object.freeze(['all', 'oidc'] as const);
|
||||
|
||||
type SeedChoice = (typeof seedChoices)[number];
|
||||
|
||||
export const seedByPool = async (pool: DatabasePool, type: SeedChoice) => {
|
||||
export const seedByPool = async (pool: DatabasePool, cloud = false) => {
|
||||
await pool.transaction(async (connection) => {
|
||||
if (type !== 'oidc') {
|
||||
// Check alteration scripts available in order to insert correct timestamp
|
||||
const latestTimestamp = await getLatestAlterationTimestamp();
|
||||
// Check alteration scripts available in order to insert correct timestamp
|
||||
const latestTimestamp = await getLatestAlterationTimestamp();
|
||||
|
||||
if (latestTimestamp < 1) {
|
||||
throw new Error(
|
||||
`No alteration script found when seeding the database.\n` +
|
||||
`Please check \`${getAlterationDirectory()}\` to see if there are alteration scripts available.\n`
|
||||
);
|
||||
}
|
||||
if (latestTimestamp < 1) {
|
||||
throw new Error(
|
||||
`No alteration script found when seeding the database.\n` +
|
||||
`Please check \`${getAlterationDirectory()}\` to see if there are alteration scripts available.\n`
|
||||
);
|
||||
}
|
||||
|
||||
await oraPromise(createTables(connection), {
|
||||
text: 'Create tables',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
await oraPromise(seedTables(connection, latestTimestamp), {
|
||||
text: 'Seed data',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
await oraPromise(createTables(connection), {
|
||||
text: 'Create tables',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
await seedTables(connection, latestTimestamp);
|
||||
|
||||
if (cloud) {
|
||||
await seedCloud(connection);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const seed: CommandModule<Record<string, unknown>, { type: string; swe?: boolean }> = {
|
||||
const seed: CommandModule<Record<string, unknown>, { swe?: boolean; cloud?: boolean }> = {
|
||||
command: 'seed [type]',
|
||||
describe: 'Create database then seed tables and data',
|
||||
builder: (yargs) =>
|
||||
|
@ -48,13 +43,12 @@ const seed: CommandModule<Record<string, unknown>, { type: string; swe?: boolean
|
|||
alias: 'skip-when-exists',
|
||||
type: 'boolean',
|
||||
})
|
||||
.positional('type', {
|
||||
describe: 'Optional seed type',
|
||||
type: 'string',
|
||||
choices: seedChoices,
|
||||
default: 'all',
|
||||
.option('cloud', {
|
||||
describe: 'Seed additional cloud data',
|
||||
type: 'boolean',
|
||||
hidden: true,
|
||||
}),
|
||||
handler: async ({ type, swe }) => {
|
||||
handler: async ({ swe, cloud }) => {
|
||||
const pool = await createPoolAndDatabaseIfNeeded();
|
||||
|
||||
if (swe && (await doesConfigsTableExist(pool))) {
|
||||
|
@ -65,10 +59,7 @@ const seed: CommandModule<Record<string, unknown>, { type: string; swe?: boolean
|
|||
}
|
||||
|
||||
try {
|
||||
// Cannot avoid `as` since the official type definition of `yargs` doesn't work.
|
||||
// The value of `type` can be ensured, so it's safe to use `as` here.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
await seedByPool(pool, type as SeedChoice);
|
||||
await seedByPool(pool, cloud);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
console.log();
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
createDefaultSignInExperience,
|
||||
createAdminTenantSignInExperience,
|
||||
createDefaultAdminConsoleApplication,
|
||||
createCloudApi,
|
||||
} from '@logto/schemas';
|
||||
import { Hooks, Tenants } from '@logto/schemas/models';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
|
@ -20,7 +21,8 @@ import { raw } from 'slonik-sql-tag-raw';
|
|||
import { insertInto } from '../../../database.js';
|
||||
import { getDatabaseName } from '../../../queries/database.js';
|
||||
import { updateDatabaseTimestamp } from '../../../queries/system.js';
|
||||
import { getPathInModule } from '../../../utils.js';
|
||||
import { getPathInModule, log } from '../../../utils.js';
|
||||
import { appendAdminConsoleRedirectUris } from './cloud.js';
|
||||
import { seedOidcConfigs } from './oidc-config.js';
|
||||
import { createTenant, seedAdminData } from './tenant.js';
|
||||
|
||||
|
@ -121,15 +123,24 @@ export const seedTables = async (
|
|||
await createTenant(connection, adminTenantId);
|
||||
await seedOidcConfigs(connection, adminTenantId);
|
||||
await seedAdminData(connection, createAdminDataInAdminTenant(defaultTenantId));
|
||||
await seedAdminData(connection, createAdminDataInAdminTenant(adminTenantId));
|
||||
await seedAdminData(connection, createMeApiInAdminTenant());
|
||||
await seedAdminData(connection, createCloudApi());
|
||||
|
||||
await Promise.all([
|
||||
connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), 'logto_configs')),
|
||||
connection.query(
|
||||
insertInto(createDefaultSignInExperience(defaultTenantId), 'sign_in_experiences')
|
||||
),
|
||||
connection.query(insertInto(createDefaultAdminConsoleConfig(adminTenantId), 'logto_configs')),
|
||||
connection.query(insertInto(createAdminTenantSignInExperience(), 'sign_in_experiences')),
|
||||
connection.query(insertInto(createDefaultAdminConsoleApplication(), 'applications')),
|
||||
updateDatabaseTimestamp(connection, latestTimestamp),
|
||||
]);
|
||||
|
||||
log.succeed('Seed data');
|
||||
};
|
||||
|
||||
export const seedCloud = async (connection: DatabaseTransactionConnection) => {
|
||||
await appendAdminConsoleRedirectUris(connection);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
import type { TenantModel, AdminData, UpdateAdminData } from '@logto/schemas';
|
||||
import { CreateRolesScope } from '@logto/schemas';
|
||||
import type { TenantModel, AdminData } from '@logto/schemas';
|
||||
import { createTenantMetadata } from '@logto/shared';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
|
@ -23,7 +23,10 @@ export const createTenant = async (pool: CommonQueryMethods, tenantId: string) =
|
|||
`);
|
||||
};
|
||||
|
||||
export const seedAdminData = async (pool: CommonQueryMethods, data: AdminData) => {
|
||||
export const seedAdminData = async (
|
||||
pool: CommonQueryMethods,
|
||||
data: AdminData | UpdateAdminData
|
||||
) => {
|
||||
const { resource, scope, role } = data;
|
||||
|
||||
assert(
|
||||
|
@ -31,14 +34,32 @@ export const seedAdminData = async (pool: CommonQueryMethods, data: AdminData) =
|
|||
new Error('All data should have the same tenant ID')
|
||||
);
|
||||
|
||||
const processRole = async () => {
|
||||
if ('id' in role) {
|
||||
await pool.query(insertInto(role, 'roles'));
|
||||
|
||||
return role.id;
|
||||
}
|
||||
|
||||
// Query by role name for existing roles
|
||||
const { id } = await pool.one<{ id: string }>(sql`
|
||||
select id from roles
|
||||
where name=${role.name}
|
||||
and tenant_id=${String(role.tenantId)}
|
||||
`);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
await pool.query(insertInto(resource, 'resources'));
|
||||
await pool.query(insertInto(scope, 'scopes'));
|
||||
await pool.query(insertInto(role, 'roles'));
|
||||
|
||||
const roleId = await processRole();
|
||||
await pool.query(
|
||||
insertInto(
|
||||
{
|
||||
id: generateStandardId(),
|
||||
roleId: role.id,
|
||||
roleId,
|
||||
scopeId: scope.id,
|
||||
tenantId: resource.tenantId,
|
||||
} satisfies CreateRolesScope,
|
||||
|
|
|
@ -136,7 +136,7 @@ export const decompress = async (toPath: string, tarPath: string) => {
|
|||
export const seedDatabase = async (instancePath: string) => {
|
||||
try {
|
||||
const pool = await createPoolAndDatabaseIfNeeded();
|
||||
await seedByPool(pool, 'all');
|
||||
await seedByPool(pool);
|
||||
await pool.end();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
],
|
||||
"watch": [
|
||||
"./src/",
|
||||
"../core/src/",
|
||||
"./node_modules/",
|
||||
"../../.env"
|
||||
],
|
||||
"ext": "json,js,jsx,ts,tsx",
|
||||
"delay": 500
|
||||
"delay": 1000
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"chalk": "^5.0.0",
|
||||
"decamelize": "^6.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fetch-retry": "^5.0.4",
|
||||
"find-up": "^6.3.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"jose": "^4.11.0",
|
||||
|
|
|
@ -5,6 +5,7 @@ import path from 'node:path/posix';
|
|||
import { tryThat } from '@logto/shared';
|
||||
import type { NextFunction, RequestContext } from '@withtyped/server';
|
||||
import { RequestError } from '@withtyped/server';
|
||||
import fetchRetry from 'fetch-retry';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -46,9 +47,13 @@ export default function withAuth<InputContext extends RequestContext>({
|
|||
audience,
|
||||
scopes: expectScopes = [],
|
||||
}: WithAuthConfig) {
|
||||
const fetch = fetchRetry(global.fetch);
|
||||
const getJwkSet = (async () => {
|
||||
const fetched = await fetch(
|
||||
new URL(path.join(endpoint.pathname, 'oidc/.well-known/openid-configuration'), endpoint)
|
||||
new Request(
|
||||
new URL(path.join(endpoint.pathname, 'oidc/.well-known/openid-configuration'), endpoint)
|
||||
),
|
||||
{ retries: 5, retryDelay: (attempt) => 2 ** attempt * 1000 }
|
||||
);
|
||||
const { jwks_uri: jwksUri, issuer } = z
|
||||
.object({ jwks_uri: z.string(), issuer: z.string() })
|
||||
|
@ -76,7 +81,8 @@ export default function withAuth<InputContext extends RequestContext>({
|
|||
audience,
|
||||
}),
|
||||
(error) => {
|
||||
throw error;
|
||||
console.error(error);
|
||||
throw new RequestError('JWT verification failed.', 401);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -81,7 +81,9 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
|
|||
set ${metadataKey} = jsonb_set(
|
||||
${metadataKey},
|
||||
'{redirectUris}',
|
||||
${metadataKey}->'redirectUris' || ${jsonb(urls.map(String))}
|
||||
(select jsonb_agg(distinct value) from jsonb_array_elements(
|
||||
${metadataKey}->'redirectUris' || ${jsonb(urls.map(String))}
|
||||
))
|
||||
)
|
||||
where id = ${adminConsoleApplicationId}
|
||||
and tenant_id = ${adminTenantId}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { GlobalValues, appendPath } from '@logto/shared';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
|
@ -5,9 +6,7 @@ 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';
|
||||
import createPool from './create-pool.js';
|
||||
import createQueryClient from './create-query-client.js';
|
||||
import loadOidcValues from './oidc.js';
|
||||
|
|
|
@ -1,28 +1,5 @@
|
|||
import chalk from 'chalk';
|
||||
|
||||
export const throwNotLoadedError = () => {
|
||||
throw new Error(
|
||||
'The env set is not loaded. Make sure to call `await envSet.load()` before using it.'
|
||||
);
|
||||
};
|
||||
|
||||
export const throwErrorWithDsnMessage = (error: unknown) => {
|
||||
const key = 'DB_URL';
|
||||
|
||||
if (error instanceof Error && error.message === `env variable ${key} not found`) {
|
||||
console.error(
|
||||
`${chalk.red('[error]')} No Postgres DSN (${chalk.green(key)}) found in env variables.\n\n` +
|
||||
` Either provide it in your env, or add it to the ${chalk.blue(
|
||||
'.env'
|
||||
)} file in the Logto project root.\n\n` +
|
||||
` If you want to set up a new Logto database, run ${chalk.green(
|
||||
'npm run cli db seed'
|
||||
)} before setting env ${chalk.green(key)}.\n\n` +
|
||||
` Visit ${chalk.blue(
|
||||
'https://docs.logto.io/docs/references/core/configuration'
|
||||
)} for more info about setting up env.\n`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import path from 'path';
|
||||
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { GlobalValues } from '@logto/shared';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { deduplicate, trySafe } from '@silverhand/essentials';
|
||||
|
||||
import type GlobalValues from './GlobalValues.js';
|
||||
|
||||
export const getTenantEndpoint = (
|
||||
id: string,
|
||||
{ urlSet, adminUrlSet, isDomainBasedMultiTenancy, isPathBasedMultiTenancy }: GlobalValues
|
||||
|
|
|
@ -7,13 +7,12 @@ import {
|
|||
LogtoOidcConfigKey,
|
||||
LogtoConfigs,
|
||||
} from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { convertToIdentifiers, appendPath } from '@logto/shared';
|
||||
import type { JWK } from 'jose';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import { exportJWK } from '#src/utils/jwks.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(LogtoConfigs);
|
||||
|
||||
|
@ -27,13 +26,9 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
keys: JWK[];
|
||||
issuer: string[];
|
||||
}> => {
|
||||
const { isDomainBasedMultiTenancy, isPathBasedMultiTenancy, adminUrlSet } = EnvSet.values;
|
||||
const { isMultiTenancy, adminUrlSet } = EnvSet.values;
|
||||
|
||||
if (
|
||||
!isDomainBasedMultiTenancy &&
|
||||
!isPathBasedMultiTenancy &&
|
||||
adminUrlSet.deduplicated().length === 0
|
||||
) {
|
||||
if (!isMultiTenancy && adminUrlSet.deduplicated().length === 0) {
|
||||
return { keys: [], issuer: [] };
|
||||
}
|
||||
|
||||
|
@ -52,9 +47,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
|
||||
issuer: [
|
||||
appendPath(
|
||||
isDomainBasedMultiTenancy || isPathBasedMultiTenancy
|
||||
? getTenantEndpoint(adminTenantId, EnvSet.values)
|
||||
: adminUrlSet.endpoint,
|
||||
isMultiTenancy ? getTenantEndpoint(adminTenantId, EnvSet.values) : adminUrlSet.endpoint,
|
||||
'/oidc'
|
||||
).toString(),
|
||||
],
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { GlobalValues, UrlSet } from '@logto/shared';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import type { RequestMethod } from 'node-mocks-http';
|
||||
|
||||
import GlobalValues from '#src/env-set/GlobalValues.js';
|
||||
import UrlSet from '#src/env-set/UrlSet.js';
|
||||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import cors from '@koa/cors';
|
||||
import type { UrlSet } from '@logto/shared';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
|
||||
import type UrlSet from '#src/env-set/UrlSet.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
export default function koaCors<StateT, ContextT, ResponseBodyT>(
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { appendPath } from '@logto/shared';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
// Need To Align With UI
|
||||
export const sessionNotFoundPath = '/unknown-session';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { CreateApplication } from '@logto/schemas';
|
||||
import { ApplicationType, adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas';
|
||||
import { tryThat } from '@logto/shared';
|
||||
import { tryThat, appendPath } from '@logto/shared';
|
||||
import { addSeconds } from 'date-fns';
|
||||
import type { AdapterFactory, AllClientMetadata } from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
|
@ -9,7 +9,6 @@ import snakecaseKeys from 'snakecase-keys';
|
|||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { getTenantUrls } from '#src/env-set/utils.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
import { getConstantClientMetadata } from './utils.js';
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ export default class Tenant implements TenantContext {
|
|||
// Mount APIs
|
||||
app.use(mount('/api', initApis(tenantContext)));
|
||||
|
||||
const { isDomainBasedMultiTenancy, isPathBasedMultiTenancy } = EnvSet.values;
|
||||
const { isMultiTenancy } = EnvSet.values;
|
||||
|
||||
// Mount admin tenant APIs and app
|
||||
if (id === adminTenantId) {
|
||||
|
@ -95,8 +95,8 @@ export default class Tenant implements TenantContext {
|
|||
app.use(mount('/me', initMeApis(tenantContext)));
|
||||
|
||||
// Mount Admin Console when needed
|
||||
// Skip in domain-based multi-tenancy since Logto Cloud serves Admin Console in this case
|
||||
if (!isDomainBasedMultiTenancy && !isPathBasedMultiTenancy) {
|
||||
// Skip in multi-tenancy mode since Logto Cloud serves Admin Console in this case
|
||||
if (!isMultiTenancy) {
|
||||
app.use(koaConsoleRedirectProxy(queries));
|
||||
app.use(
|
||||
mount(
|
||||
|
@ -111,7 +111,7 @@ export default class Tenant implements TenantContext {
|
|||
// while distinguishing "demo app from admin tenant" and "demo app from user tenant";
|
||||
// on the cloud, we need to configure admin tenant sign-in experience, so a preview is needed for
|
||||
// testing without signing out of the admin console.
|
||||
if (id !== adminTenantId || isDomainBasedMultiTenancy || isPathBasedMultiTenancy) {
|
||||
if (id !== adminTenantId || isMultiTenancy) {
|
||||
// Mount demo app
|
||||
app.use(
|
||||
mount(
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import { GlobalValues } from '@logto/shared';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import GlobalValues from '#src/env-set/GlobalValues.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import type { UrlSet } from '@logto/shared';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
|
||||
import type UrlSet from '#src/env-set/UrlSet.js';
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
|
||||
const normalizePathname = (pathname: string) =>
|
||||
|
@ -43,7 +43,7 @@ const matchPathBasedTenantId = (urlSet: UrlSet, url: URL) => {
|
|||
|
||||
export const getTenantId = (url: URL) => {
|
||||
const {
|
||||
isDomainBasedMultiTenancy,
|
||||
isMultiTenancy,
|
||||
isPathBasedMultiTenancy,
|
||||
isProduction,
|
||||
isIntegrationTest,
|
||||
|
@ -62,7 +62,7 @@ export const getTenantId = (url: URL) => {
|
|||
return developmentTenantId;
|
||||
}
|
||||
|
||||
if (!isDomainBasedMultiTenancy && !isPathBasedMultiTenancy) {
|
||||
if (!isMultiTenancy) {
|
||||
return defaultTenantId;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const adminTenantId = 'admin';
|
||||
|
||||
const addApiData = async (pool: CommonQueryMethods) => {
|
||||
const adminApi = {
|
||||
resourceId: generateStandardId(),
|
||||
scopeId: generateStandardId(),
|
||||
};
|
||||
const cloudApi = {
|
||||
resourceId: generateStandardId(),
|
||||
scopeId: generateStandardId(),
|
||||
};
|
||||
const adminRole = {
|
||||
id: generateStandardId(),
|
||||
name: 'admin:admin',
|
||||
description: 'Admin role for Logto.',
|
||||
};
|
||||
|
||||
await pool.query(sql`
|
||||
insert into resources (tenant_id, id, indicator, name)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${adminApi.resourceId},
|
||||
'https://admin.logto.app/api',
|
||||
'Logto Management API for tenant admin'
|
||||
), (
|
||||
${adminTenantId},
|
||||
${cloudApi.resourceId},
|
||||
'https://cloud.logto.io/api',
|
||||
'Logto Cloud API'
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into scopes (tenant_id, id, name, description, resource_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${adminApi.scopeId},
|
||||
'all',
|
||||
'Default scope for Management API, allows all permissions.',
|
||||
${adminApi.resourceId}
|
||||
), (
|
||||
${adminTenantId},
|
||||
${cloudApi.scopeId},
|
||||
'create:tenant',
|
||||
'Allow creating new tenants.',
|
||||
${cloudApi.resourceId}
|
||||
);
|
||||
`);
|
||||
await pool.query(sql`
|
||||
insert into roles (tenant_id, id, name, description)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${adminRole.id},
|
||||
${adminRole.name},
|
||||
${adminRole.description}
|
||||
);
|
||||
`);
|
||||
|
||||
const { id: userRoleId } = await pool.one<{ id: string }>(sql`
|
||||
select id from roles
|
||||
where tenant_id = ${adminTenantId}
|
||||
and name = 'user'
|
||||
`);
|
||||
|
||||
await pool.query(sql`
|
||||
insert into roles_scopes (tenant_id, id, role_id, scope_id)
|
||||
values (
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${userRoleId},
|
||||
${cloudApi.scopeId}
|
||||
), (
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${adminRole.id},
|
||||
${adminApi.scopeId}
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await addApiData(pool);
|
||||
await pool.query(sql`
|
||||
insert into logto_configs (tenant_id, key, value)
|
||||
values (
|
||||
${adminTenantId},
|
||||
'adminConsole',
|
||||
${sql.jsonb({
|
||||
language: 'en',
|
||||
appearanceMode: 'system',
|
||||
livePreviewChecked: false,
|
||||
applicationCreated: false,
|
||||
signInExperienceCustomized: false,
|
||||
passwordlessConfigured: false,
|
||||
selfHostingChecked: false,
|
||||
communityChecked: false,
|
||||
m2mApplicationCreated: false,
|
||||
})}
|
||||
);
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
delete from resources
|
||||
where tenant_id = ${adminTenantId}
|
||||
and indicator in ('https://admin.logto.app/api', 'https://cloud.logto.io/api');
|
||||
`);
|
||||
await pool.query(sql`
|
||||
delete from roles
|
||||
where tenant_id = ${adminTenantId}
|
||||
and name = 'admin:admin';
|
||||
`);
|
||||
await pool.query(sql`
|
||||
delete from logto_configs
|
||||
where tenant_id = ${adminTenantId}
|
||||
and key = 'adminConsole';
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,10 +1,3 @@
|
|||
/** The API Resource Indicator for Logto Cloud. It's only useful when domain-based multi-tenancy is enabled. */
|
||||
export const cloudApiIndicator = 'https://cloud.logto.io/api';
|
||||
|
||||
export enum CloudScope {
|
||||
CreateTenant = 'create:tenant',
|
||||
}
|
||||
|
||||
/**
|
||||
* In OSS:
|
||||
*
|
||||
|
|
36
packages/schemas/src/seeds/cloud-api.ts
Normal file
36
packages/schemas/src/seeds/cloud-api.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
import { UserRole } from '../types/index.js';
|
||||
import type { UpdateAdminData } from './management-api.js';
|
||||
import { adminTenantId } from './tenant.js';
|
||||
|
||||
/** The API Resource Indicator for Logto Cloud. It's only useful when domain-based multi-tenancy is enabled. */
|
||||
export const cloudApiIndicator = 'https://cloud.logto.io/api';
|
||||
|
||||
export enum CloudScope {
|
||||
CreateTenant = 'create:tenant',
|
||||
}
|
||||
|
||||
export const createCloudApi = (): Readonly<UpdateAdminData> => {
|
||||
const resourceId = generateStandardId();
|
||||
|
||||
return Object.freeze({
|
||||
resource: {
|
||||
tenantId: adminTenantId,
|
||||
id: resourceId,
|
||||
indicator: cloudApiIndicator,
|
||||
name: `Logto Cloud API`,
|
||||
},
|
||||
scope: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: CloudScope.CreateTenant,
|
||||
description: 'Allow creating new tenants.',
|
||||
resourceId,
|
||||
},
|
||||
role: {
|
||||
tenantId: adminTenantId,
|
||||
name: UserRole.User,
|
||||
},
|
||||
});
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
export * from './application.js';
|
||||
export * from './cloud-api.js';
|
||||
export * from './management-api.js';
|
||||
export * from './logto-config.js';
|
||||
export * from './sign-in-experience.js';
|
||||
|
|
|
@ -10,6 +10,11 @@ export type AdminData = {
|
|||
role: CreateRole;
|
||||
};
|
||||
|
||||
export type UpdateAdminData = Omit<AdminData, 'role'> & {
|
||||
/** Attach to an existing role instead of creating one. */
|
||||
role: Pick<CreateRole, 'tenantId' | 'name'>;
|
||||
};
|
||||
|
||||
// Consider remove the dependency of IDs
|
||||
const defaultResourceId = 'management-api';
|
||||
const defaultScopeAllId = 'management-api-all';
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
"@logto/core-kit": "workspace:*",
|
||||
"@logto/schemas": "workspace:*",
|
||||
"@silverhand/essentials": "2.2.0",
|
||||
"chalk": "^5.0.0",
|
||||
"find-up": "^6.3.0",
|
||||
"nanoid": "^4.0.0",
|
||||
"slonik": "^30.0.0"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { tryThat } from '@logto/shared';
|
||||
import { assertEnv, getEnv, getEnvAsStringArray, yes } from '@silverhand/essentials';
|
||||
|
||||
import { tryThat } from '../utils/index.js';
|
||||
import UrlSet from './UrlSet.js';
|
||||
import { throwErrorWithDsnMessage } from './throw-errors.js';
|
||||
|
||||
|
@ -80,6 +80,11 @@ export default class GlobalValues {
|
|||
public readonly isPathBasedMultiTenancy =
|
||||
!this.isDomainBasedMultiTenancy && yes(getEnv('PATH_BASED_MULTI_TENANCY'));
|
||||
|
||||
/** Alias for `isDomainBasedMultiTenancy || isPathBasedMultiTenancy`. */
|
||||
public get isMultiTenancy(): boolean {
|
||||
return this.isDomainBasedMultiTenancy || this.isPathBasedMultiTenancy;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
|
||||
public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID');
|
28
packages/shared/src/env/throw-errors.ts
vendored
Normal file
28
packages/shared/src/env/throw-errors.ts
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
import chalk from 'chalk';
|
||||
|
||||
export const throwNotLoadedError = () => {
|
||||
throw new Error(
|
||||
'The env set is not loaded. Make sure to call `await envSet.load()` before using it.'
|
||||
);
|
||||
};
|
||||
|
||||
export const throwErrorWithDsnMessage = (error: unknown) => {
|
||||
const key = 'DB_URL';
|
||||
|
||||
if (error instanceof Error && error.message === `env variable ${key} not found`) {
|
||||
console.error(
|
||||
`${chalk.red('[error]')} No Postgres DSN (${chalk.green(key)}) found in env variables.\n\n` +
|
||||
` Either provide it in your env, or add it to the ${chalk.blue(
|
||||
'.env'
|
||||
)} file in the Logto project root.\n\n` +
|
||||
` If you want to set up a new Logto database, run ${chalk.green(
|
||||
'npm run cli db seed'
|
||||
)} before setting env ${chalk.green(key)}.\n\n` +
|
||||
` Visit ${chalk.blue(
|
||||
'https://docs.logto.io/docs/references/core/configuration'
|
||||
)} for more info about setting up env.\n`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
|
@ -1,3 +1,5 @@
|
|||
export * from './database/index.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './models/index.js';
|
||||
export { default as UrlSet } from './env/UrlSet.js';
|
||||
export { default as GlobalValues } from './env/GlobalValues.js';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './function.js';
|
||||
export * from './object.js';
|
||||
export { default as findPackage } from './find-package.js';
|
||||
export * from './url.js';
|
||||
|
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
@ -122,6 +122,7 @@ importers:
|
|||
decamelize: ^6.0.0
|
||||
dotenv: ^16.0.0
|
||||
eslint: ^8.21.0
|
||||
fetch-retry: ^5.0.4
|
||||
find-up: ^6.3.0
|
||||
http-proxy: ^1.18.1
|
||||
jose: ^4.11.0
|
||||
|
@ -142,6 +143,7 @@ importers:
|
|||
chalk: 5.1.2
|
||||
decamelize: 6.0.0
|
||||
dotenv: 16.0.0
|
||||
fetch-retry: 5.0.4
|
||||
find-up: 6.3.0
|
||||
http-proxy: 1.18.1
|
||||
jose: 4.11.1
|
||||
|
@ -704,6 +706,7 @@ importers:
|
|||
'@silverhand/ts-config': 2.0.3
|
||||
'@types/jest': ^29.1.2
|
||||
'@types/node': ^18.11.18
|
||||
chalk: ^5.0.0
|
||||
eslint: ^8.34.0
|
||||
find-up: ^6.3.0
|
||||
jest: ^29.1.2
|
||||
|
@ -716,6 +719,7 @@ importers:
|
|||
'@logto/core-kit': link:../toolkit/core-kit
|
||||
'@logto/schemas': link:../schemas
|
||||
'@silverhand/essentials': 2.2.0
|
||||
chalk: 5.1.2
|
||||
find-up: 6.3.0
|
||||
nanoid: 4.0.0
|
||||
slonik: 30.1.2
|
||||
|
@ -7127,6 +7131,10 @@ packages:
|
|||
web-streams-polyfill: 3.2.1
|
||||
dev: true
|
||||
|
||||
/fetch-retry/5.0.4:
|
||||
resolution: {integrity: sha512-LXcdgpdcVedccGg0AZqg+S8lX/FCdwXD92WNZ5k5qsb0irRhSFsBOpcJt7oevyqT2/C2nEE0zSFNdBEpj3YOSw==}
|
||||
dev: false
|
||||
|
||||
/figures/5.0.0:
|
||||
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
Loading…
Add table
Reference in a new issue