0
Fork 0
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:
Gao Sun 2023-03-03 16:45:02 +08:00 committed by GitHub
commit 0a3d4bb345
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 383 additions and 114 deletions

View 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`

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,9 +6,10 @@
],
"watch": [
"./src/",
"../core/src/",
"./node_modules/",
"../../.env"
],
"ext": "json,js,jsx,ts,tsx",
"delay": 500
"delay": 1000
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:
*

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

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