From 08e7b6c6c2667451cf349f18a0cbd89425d80536 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 4 Mar 2023 14:52:02 +0800 Subject: [PATCH] refactor: allow admin to create tenants --- .changeset-staged/gold-mugs-allow.md | 16 ++++++ packages/cli/package.json | 2 +- .../cli/src/commands/database/seed/tables.ts | 17 +++++- .../cli/src/commands/database/seed/tenant.ts | 29 +++++++++- packages/cloud/package.json | 2 +- packages/cloud/src/routes/tenants.ts | 8 ++- packages/console/package.json | 2 +- packages/console/src/App.tsx | 55 +++++++++--------- .../src/components/SessionExpired/index.tsx | 15 +++-- .../src/containers/AppLayout/index.tsx | 2 +- .../src/containers/ErrorBoundary/index.tsx | 40 +++++++------ packages/console/src/hooks/use-api.ts | 16 +++--- packages/console/src/pages/Welcome/index.tsx | 2 +- packages/core/package.json | 2 +- .../interaction/actions/submit-interaction.ts | 3 +- packages/core/src/utils/array.ts | 5 -- packages/integration-tests/package.json | 2 +- packages/phrases-ui/package.json | 2 +- packages/phrases/package.json | 2 +- ...982-allow-admin-create-multiple-tenants.ts | 56 +++++++++++++++++++ packages/schemas/package.json | 2 +- packages/schemas/src/seeds/cloud-api.ts | 49 +++++++++------- packages/shared/package.json | 2 +- packages/toolkit/connector-kit/package.json | 2 +- packages/toolkit/core-kit/package.json | 2 +- packages/ui/package.json | 2 +- pnpm-lock.yaml | 52 ++++++++--------- 27 files changed, 255 insertions(+), 134 deletions(-) create mode 100644 .changeset-staged/gold-mugs-allow.md delete mode 100644 packages/core/src/utils/array.ts create mode 100644 packages/schemas/alterations/next-1677907982-allow-admin-create-multiple-tenants.ts diff --git a/.changeset-staged/gold-mugs-allow.md b/.changeset-staged/gold-mugs-allow.md new file mode 100644 index 000000000..0a9cbae86 --- /dev/null +++ b/.changeset-staged/gold-mugs-allow.md @@ -0,0 +1,16 @@ +--- +"@logto/cli": minor +"@logto/cloud": minor +"@logto/console": minor +"@logto/core": minor +"@logto/integration-tests": minor +"@logto/phrases": minor +"@logto/phrases-ui": minor +"@logto/schemas": minor +"@logto/shared": minor +"@logto/connector-kit": minor +"@logto/core-kit": minor +"@logto/ui": minor +--- + +Allow admin tenant admin to create tenants without limitation diff --git a/packages/cli/package.json b/packages/cli/package.json index d05abc2e3..673f45e4f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,7 +46,7 @@ "@logto/core-kit": "workspace:*", "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", - "@silverhand/essentials": "2.2.0", + "@silverhand/essentials": "2.3.0", "chalk": "^5.0.0", "decamelize": "^6.0.0", "dotenv": "^16.0.0", diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index e8526d0c3..e728e897c 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -24,7 +24,7 @@ import { updateDatabaseTimestamp } from '../../../queries/system.js'; import { getPathInModule, log } from '../../../utils.js'; import { appendAdminConsoleRedirectUris } from './cloud.js'; import { seedOidcConfigs } from './oidc-config.js'; -import { createTenant, seedAdminData } from './tenant.js'; +import { assignScopesToRole, createTenant, seedAdminData } from './tenant.js'; const getExplicitOrder = (query: string) => { const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1]; @@ -123,9 +123,20 @@ export const seedTables = async ( await createTenant(connection, adminTenantId); await seedOidcConfigs(connection, adminTenantId); await seedAdminData(connection, createAdminDataInAdminTenant(defaultTenantId)); - await seedAdminData(connection, createAdminDataInAdminTenant(adminTenantId)); + const adminAdminData = createAdminDataInAdminTenant(adminTenantId); + await seedAdminData(connection, adminAdminData); await seedAdminData(connection, createMeApiInAdminTenant()); - await seedAdminData(connection, createCloudApi()); + + const [cloudData, ...cloudAdditionalScopes] = createCloudApi(); + await seedAdminData(connection, cloudData, ...cloudAdditionalScopes); + + // Assign all cloud API scopes to role `admin:admin` + await assignScopesToRole( + connection, + adminTenantId, + adminAdminData.role.id, + ...cloudAdditionalScopes.map(({ id }) => id) + ); await Promise.all([ connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), 'logto_configs')), diff --git a/packages/cli/src/commands/database/seed/tenant.ts b/packages/cli/src/commands/database/seed/tenant.ts index be7f40f33..d09604407 100644 --- a/packages/cli/src/commands/database/seed/tenant.ts +++ b/packages/cli/src/commands/database/seed/tenant.ts @@ -1,5 +1,5 @@ import { generateStandardId } from '@logto/core-kit'; -import type { TenantModel, AdminData, UpdateAdminData } from '@logto/schemas'; +import type { TenantModel, AdminData, UpdateAdminData, CreateScope } from '@logto/schemas'; import { CreateRolesScope } from '@logto/schemas'; import { createTenantMetadata } from '@logto/shared'; import { assert } from '@silverhand/essentials'; @@ -25,7 +25,8 @@ export const createTenant = async (pool: CommonQueryMethods, tenantId: string) = export const seedAdminData = async ( pool: CommonQueryMethods, - data: AdminData | UpdateAdminData + data: AdminData | UpdateAdminData, + ...additionalScopes: CreateScope[] ) => { const { resource, scope, role } = data; @@ -53,6 +54,7 @@ export const seedAdminData = async ( await pool.query(insertInto(resource, 'resources')); await pool.query(insertInto(scope, 'scopes')); + await Promise.all(additionalScopes.map(async (scope) => pool.query(insertInto(scope, 'scopes')))); const roleId = await processRole(); await pool.query( @@ -67,3 +69,26 @@ export const seedAdminData = async ( ) ); }; + +export const assignScopesToRole = async ( + pool: CommonQueryMethods, + tenantId: string, + roleId: string, + ...scopeIds: string[] +) => { + await Promise.all( + scopeIds.map(async (scopeId) => + pool.query( + insertInto( + { + id: generateStandardId(), + roleId, + scopeId, + tenantId, + } satisfies CreateRolesScope, + 'roles_scopes' + ) + ) + ) + ); +}; diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 87f16cb0a..0f9ff988e 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -23,7 +23,7 @@ "@logto/core-kit": "workspace:*", "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", - "@silverhand/essentials": "2.2.0", + "@silverhand/essentials": "2.3.0", "@withtyped/postgres": "^0.8.1", "@withtyped/server": "^0.8.0", "chalk": "^5.0.0", diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts index 4df4147d7..f1244b71d 100644 --- a/packages/cloud/src/routes/tenants.ts +++ b/packages/cloud/src/routes/tenants.ts @@ -12,13 +12,17 @@ export const tenants = createRouter('/tenants') return next({ ...context, json: await library.getAvailableTenants(context.auth.id) }); }) .post('/', { response: tenantInfoGuard }, async (context, next) => { - if (!context.auth.scopes.includes(CloudScope.CreateTenant)) { + if ( + ![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) => + context.auth.scopes.includes(scope) + ) + ) { throw new RequestError('Forbidden due to lack of permission.', 403); } const tenants = await library.getAvailableTenants(context.auth.id); - if (tenants.length > 0) { + if (!context.auth.scopes.includes(CloudScope.ManageTenant) && tenants.length > 0) { throw new RequestError('The user already has a tenant.', 409); } diff --git a/packages/console/package.json b/packages/console/package.json index a591ef1b8..5b3deda60 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -33,7 +33,7 @@ "@parcel/transformer-svg-react": "2.8.3", "@silverhand/eslint-config": "2.0.1", "@silverhand/eslint-config-react": "2.0.1", - "@silverhand/essentials": "2.2.0", + "@silverhand/essentials": "2.3.0", "@silverhand/ts-config": "2.0.3", "@silverhand/ts-config-react": "2.0.3", "@tsconfig/docusaurus": "^1.0.5", diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index edcbb41c9..463b5763e 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -1,7 +1,7 @@ import { UserScope } from '@logto/core-kit'; import { LogtoProvider } from '@logto/react'; import { adminConsoleApplicationId, PredefinedScope } from '@logto/schemas'; -import { deduplicate } from '@silverhand/essentials'; +import { conditionalArray, deduplicate } from '@silverhand/essentials'; import { useContext } from 'react'; import 'overlayscrollbars/styles/overlayscrollbars.css'; @@ -28,21 +28,26 @@ void initI18n(); const Content = () => { const { tenants, isSettle, currentTenantId } = useContext(TenantsContext); - const resources = deduplicate([ - // Explicitly add `currentTenantId` and deduplicate since the user may directly - // access a URL with Tenant ID, adding the ID from the URL here can possibly remove one - // additional redirect. - ...(currentTenantId && [getManagementApi(currentTenantId).indicator]), - ...(tenants ?? []).map(({ id }) => getManagementApi(id).indicator), - ...(isCloud ? [cloudApi.indicator] : []), - meApi.indicator, - ]); + const resources = deduplicate( + conditionalArray( + // Explicitly add `currentTenantId` and deduplicate since the user may directly + // access a URL with Tenant ID, adding the ID from the URL here can possibly remove one + // additional redirect. + currentTenantId && getManagementApi(currentTenantId).indicator, + ...(tenants ?? []).map(({ id }) => getManagementApi(id).indicator), + isCloud && cloudApi.indicator, + meApi.indicator + ) + ); const scopes = [ UserScope.Email, UserScope.Identities, UserScope.CustomData, PredefinedScope.All, - cloudApi.scopes.CreateTenant, // It's fine to keep scope here since core will filter + ...conditionalArray( + isCloud && cloudApi.scopes.CreateTenant, + isCloud && cloudApi.scopes.ManageTenant + ), ]; return ( @@ -54,26 +59,26 @@ const Content = () => { scopes, }} > - {!isCloud || isSettle ? ( - - -
- - - ) : ( - - )} + + {!isCloud || isSettle ? ( + + +
+ + + ) : ( + + )} + ); }; const App = () => { return ( - - - - - + + + ); }; export default App; diff --git a/packages/console/src/components/SessionExpired/index.tsx b/packages/console/src/components/SessionExpired/index.tsx index 79987f677..4bb6cf896 100644 --- a/packages/console/src/components/SessionExpired/index.tsx +++ b/packages/console/src/components/SessionExpired/index.tsx @@ -1,21 +1,24 @@ import { useLogto } from '@logto/react'; import { useTranslation } from 'react-i18next'; -import { useHref } from 'react-router-dom'; import AppError from '../AppError'; import Button from '../Button'; import * as styles from './index.module.scss'; -const SessionExpired = () => { - const { error, signIn } = useLogto(); - const href = useHref('/callback'); +type Props = { + error: Error; + callbackHref?: string; +}; + +const SessionExpired = ({ callbackHref = '/callback', error }: Props) => { + const { signIn, signOut } = useLogto(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); return (