diff --git a/packages/cloud/src/libraries/tenants.ts b/packages/cloud/src/libraries/tenants.ts index 779c86760..78a9f00b3 100644 --- a/packages/cloud/src/libraries/tenants.ts +++ b/packages/cloud/src/libraries/tenants.ts @@ -4,8 +4,10 @@ import { } from '@logto/cli/lib/commands/database/utils.js'; import { DemoConnector } from '@logto/connector-kit'; import { createTenantMetadata } from '@logto/core-kit'; -import type { LogtoOidcConfigType, TenantInfo, CreateTenant } from '@logto/schemas'; import { + type LogtoOidcConfigType, + type TenantInfo, + type CreateTenant, createAdminTenantApplicationRole, AdminTenantRole, createTenantMachineToMachineApplication, @@ -17,11 +19,10 @@ import { adminTenantId, createAdminData, createAdminDataInAdminTenant, + getManagementApiResourceIndicator, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { appendPath } from '@silverhand/essentials'; -import type { ZodType } from 'zod'; -import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import { createApplicationsQueries } from '#src/queries/application.js'; @@ -38,11 +39,6 @@ import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js'; const demoSocialConnectorId = 'logto-social-demo'; -export const tenantInfoGuard: ZodType = z.object({ - id: z.string(), - indicator: z.string(), -}); - const oidcConfigBuilders: { [key in LogtoOidcConfigKey]: () => Promise; } = { @@ -54,23 +50,40 @@ export class TenantsLibrary { constructor(public readonly queries: Queries) {} async getAvailableTenants(userId: string): Promise { - const { getManagementApiLikeIndicatorsForUser } = this.queries.tenants; + const { getManagementApiLikeIndicatorsForUser, getTenantsByIds } = this.queries.tenants; const { rows } = await getManagementApiLikeIndicatorsForUser(userId); - return rows - .map(({ indicator }) => ({ - id: getTenantIdFromManagementApiIndicator(indicator), - indicator, - })) - .filter((tenant): tenant is TenantInfo => Boolean(tenant.id)); + const tenantIds = rows + .map(({ indicator }) => getTenantIdFromManagementApiIndicator(indicator)) + .filter((id): id is string => typeof id === 'string'); + + if (tenantIds.length === 0) { + return []; + } + + const rawTenantInfos = await getTenantsByIds(tenantIds); + return rawTenantInfos.map(({ id, name, tag }) => ({ + id, + name, + tag, + indicator: getManagementApiResourceIndicator(id), + })); } - async createNewTenant(forUserId: string): Promise { + async createNewTenant( + forUserId: string, + payload: Pick + ): Promise { const databaseName = await getDatabaseName(this.queries.client); const { id: tenantId, parentRole, role, password } = createTenantMetadata(databaseName); // Init tenant - const createTenant: CreateTenant = { id: tenantId, dbUser: role, dbUserPassword: password }; + const createTenant: CreateTenant = { + id: tenantId, + dbUser: role, + dbUserPassword: password, + ...payload, + }; const transaction = await this.queries.client.transaction(); const tenants = createTenantsQueries(transaction); const users = createUsersQueries(transaction); @@ -84,6 +97,7 @@ export class TenantsLibrary { // Init tenant await tenants.insertTenant(createTenant); + const insertedTenant = await tenants.getTenantById(tenantId); await tenants.createTenantRole(parentRole, role, password); // Create admin data set (resource, roles, etc.) @@ -168,6 +182,11 @@ export class TenantsLibrary { await transaction.end(); /* === End === */ - return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator }; + return { + id: tenantId, + name: insertedTenant.name, + tag: insertedTenant.tag, + indicator: adminDataInAdminTenant.resource.indicator, + }; } } diff --git a/packages/cloud/src/queries/tenants.ts b/packages/cloud/src/queries/tenants.ts index d7ba05519..46fda6519 100644 --- a/packages/cloud/src/queries/tenants.ts +++ b/packages/cloud/src/queries/tenants.ts @@ -5,11 +5,14 @@ import { adminTenantId, getManagementApiResourceIndicator, PredefinedScope, + type AdminData, + type CreateTenant, + type CreateRolesScope, + type TenantInfo, } from '@logto/schemas'; -import type { AdminData, CreateTenant, CreateRolesScope } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import type { PostgreSql } from '@withtyped/postgres'; -import { jsonb, dangerousRaw, id, sql } from '@withtyped/postgres'; +import { jsonb, dangerousRaw, id, sql, jsonIfNeeded } from '@withtyped/postgres'; import type { Queryable } from '@withtyped/server'; import { insertInto } from '#src/utils/query.js'; @@ -34,7 +37,14 @@ export const createTenantsQueries = (client: Queryable) => { where roles.tenant_id = ${adminTenantId}; `); - const insertTenant = async (tenant: CreateTenant) => client.query(insertInto(tenant, 'tenants')); + const insertTenant = async (tenant: CreateTenant) => + client.query( + insertInto( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + Object.fromEntries(Object.entries(tenant).filter(([_, value]) => value !== undefined)), + 'tenants' + ) + ); const createTenantRole = async (parentRole: string, role: string, password: string) => client.query(sql` @@ -80,6 +90,25 @@ export const createTenantsQueries = (client: Queryable) => { ); }; + const getTenantById = async (id: string): Promise> => { + return client.one>(sql` + select name, tag from tenants + where id = ${id} + `); + }; + + const getTenantsByIds = async ( + tenantIds: string[] + ): Promise>> => { + const { rows } = await client.query>(sql` + select id, name, tag from tenants + where id in (${tenantIds.map((tenantId) => jsonIfNeeded(tenantId))}) + order by created_at desc, name desc; + `); + + return rows; + }; + const appendAdminConsoleRedirectUris = async (...urls: URL[]) => { const metadataKey = id('oidc_client_metadata'); @@ -102,6 +131,8 @@ export const createTenantsQueries = (client: Queryable) => { insertTenant, createTenantRole, insertAdminData, + getTenantById, + getTenantsByIds, appendAdminConsoleRedirectUris, }; }; diff --git a/packages/cloud/src/routes/tenants.test.ts b/packages/cloud/src/routes/tenants.test.ts index cf1f1573a..006a5aab0 100644 --- a/packages/cloud/src/routes/tenants.test.ts +++ b/packages/cloud/src/routes/tenants.test.ts @@ -1,5 +1,5 @@ import type { TenantInfo } from '@logto/schemas'; -import { CloudScope } from '@logto/schemas'; +import { CloudScope, TenantTag } from '@logto/schemas'; import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js'; import { noop } from '#src/test-utils/function.js'; @@ -12,13 +12,21 @@ describe('GET /api/tenants', () => { const router = tenantsRoutes(library); it('should return whatever the library returns', async () => { - const tenants: TenantInfo[] = [{ id: 'tenant_a', indicator: 'https://foo.bar' }]; + const tenants: TenantInfo[] = [ + { + id: 'tenant_a', + name: 'tenant_a', + tag: TenantTag.Development, + indicator: 'https://foo.bar', + }, + ]; library.getAvailableTenants.mockResolvedValueOnce(tenants); await router.routes()( buildRequestAuthContext('GET /tenants')(), - async ({ json }) => { + async ({ json, status }) => { expect(json).toBe(tenants); + expect(status).toBe(200); }, createHttpContext() ); @@ -30,48 +38,90 @@ describe('POST /api/tenants', () => { const router = tenantsRoutes(library); it('should throw 403 when lack of permission', async () => { - await expect( - router.routes()(buildRequestAuthContext('POST /tenants')(), noop, createHttpContext()) - ).rejects.toMatchObject({ status: 403 }); - }); - - it('should throw 409 when user has a tenant', async () => { - const tenant: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' }; - library.getAvailableTenants.mockResolvedValueOnce([tenant]); - await expect( router.routes()( - buildRequestAuthContext('POST /tenants')([CloudScope.CreateTenant]), + buildRequestAuthContext('POST /tenants', { + body: { name: 'tenant', tag: TenantTag.Development }, + })(), noop, createHttpContext() ) - ).rejects.toMatchObject({ status: 409 }); + ).rejects.toMatchObject({ status: 403 }); }); it('should be able to create a new tenant', async () => { - const tenant: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' }; + const tenant: TenantInfo = { + id: 'tenant_a', + name: 'tenant_a', + tag: TenantTag.Development, + indicator: 'https://foo.bar', + }; library.getAvailableTenants.mockResolvedValueOnce([]); library.createNewTenant.mockResolvedValueOnce(tenant); await router.routes()( - buildRequestAuthContext('POST /tenants')([CloudScope.CreateTenant]), - async ({ json }) => { + buildRequestAuthContext('POST /tenants', { + body: { name: 'tenant_a', tag: TenantTag.Development }, + })([CloudScope.CreateTenant]), + async ({ json, status }) => { expect(json).toBe(tenant); + expect(status).toBe(201); + }, + createHttpContext() + ); + }); + + it('should be able to create a new tenant with `create:tenant` scope even if user has a tenant', async () => { + const tenantA: TenantInfo = { + id: 'tenant_a', + name: 'tenant_a', + tag: TenantTag.Development, + indicator: 'https://foo.bar', + }; + const tenantB: TenantInfo = { + id: 'tenant_b', + name: 'tenant_b', + tag: TenantTag.Development, + indicator: 'https://foo.baz', + }; + library.getAvailableTenants.mockResolvedValueOnce([tenantA]); + library.createNewTenant.mockResolvedValueOnce(tenantB); + + await router.routes()( + buildRequestAuthContext('POST /tenants', { + body: { name: 'tenant_b', tag: TenantTag.Development }, + })([CloudScope.CreateTenant]), + async ({ json, status }) => { + expect(json).toBe(tenantB); + expect(status).toBe(201); }, createHttpContext() ); }); it('should be able to create a new tenant with `manage:tenant` scope even if user has a tenant', async () => { - const tenantA: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' }; - const tenantB: TenantInfo = { id: 'tenant_b', indicator: 'https://foo.baz' }; + const tenantA: TenantInfo = { + id: 'tenant_a', + name: 'tenant_a', + tag: TenantTag.Development, + indicator: 'https://foo.bar', + }; + const tenantB: TenantInfo = { + id: 'tenant_b', + name: 'tenant_b', + tag: TenantTag.Development, + indicator: 'https://foo.baz', + }; library.getAvailableTenants.mockResolvedValueOnce([tenantA]); library.createNewTenant.mockResolvedValueOnce(tenantB); await router.routes()( - buildRequestAuthContext('POST /tenants')([CloudScope.ManageTenant]), - async ({ json }) => { + buildRequestAuthContext('POST /tenants', { + body: { name: 'tenant_b', tag: TenantTag.Development }, + })([CloudScope.ManageTenant]), + async ({ json, status }) => { expect(json).toBe(tenantB); + expect(status).toBe(201); }, createHttpContext() ); diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts index b95c8d4e3..5fddcd42a 100644 --- a/packages/cloud/src/routes/tenants.ts +++ b/packages/cloud/src/routes/tenants.ts @@ -1,29 +1,37 @@ -import { CloudScope } from '@logto/schemas'; +import { CloudScope, tenantInfoGuard, createTenantGuard } from '@logto/schemas'; import { createRouter, RequestError } from '@withtyped/server'; import type { TenantsLibrary } from '#src/libraries/tenants.js'; -import { tenantInfoGuard } from '#src/libraries/tenants.js'; import type { WithAuthContext } from '#src/middleware/with-auth.js'; export const tenantsRoutes = (library: TenantsLibrary) => createRouter('/tenants') .get('/', { response: tenantInfoGuard.array() }, async (context, next) => { - return next({ ...context, json: await library.getAvailableTenants(context.auth.id) }); + return next({ + ...context, + json: await library.getAvailableTenants(context.auth.id), + status: 200, + }); }) - .post('/', { response: tenantInfoGuard }, async (context, next) => { - if ( - ![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) => - context.auth.scopes.includes(scope) - ) - ) { - throw new RequestError('Forbidden due to lack of permission.', 403); + .post( + '/', + { + body: createTenantGuard.pick({ name: true, tag: true }).required(), + response: tenantInfoGuard, + }, + async (context, next) => { + if ( + ![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) => + context.auth.scopes.includes(scope) + ) + ) { + throw new RequestError('Forbidden due to lack of permission.', 403); + } + + return next({ + ...context, + json: await library.createNewTenant(context.auth.id, context.guarded.body), + status: 201, + }); } - - const tenants = await library.getAvailableTenants(context.auth.id); - - if (!context.auth.scopes.includes(CloudScope.ManageTenant) && tenants.length > 0) { - throw new RequestError('The user already has a tenant.', 409); - } - - return next({ ...context, json: await library.createNewTenant(context.auth.id) }); - }); + ); diff --git a/packages/cloud/src/test-utils/libraries.ts b/packages/cloud/src/test-utils/libraries.ts index 3ddf9f365..f675835a4 100644 --- a/packages/cloud/src/test-utils/libraries.ts +++ b/packages/cloud/src/test-utils/libraries.ts @@ -12,7 +12,7 @@ export class MockTenantsLibrary implements TenantsLibrary { } public getAvailableTenants = jest.fn, [string]>(); - public createNewTenant = jest.fn, [string]>(); + public createNewTenant = jest.fn, [string, Record]>(); } export class MockServicesLibrary implements ServicesLibrary { diff --git a/packages/console/src/cloud/pages/Main/Tenants.tsx b/packages/console/src/cloud/pages/Main/Tenants.tsx index 0534dbeda..a0a2b4ea4 100644 --- a/packages/console/src/cloud/pages/Main/Tenants.tsx +++ b/packages/console/src/cloud/pages/Main/Tenants.tsx @@ -1,4 +1,4 @@ -import type { TenantInfo } from '@logto/schemas'; +import { type TenantInfo, TenantTag } from '@logto/schemas'; import { useCallback, useContext, useEffect } from 'react'; import { useCloudApi } from '@/cloud/hooks/use-cloud-api'; @@ -19,7 +19,15 @@ function Tenants({ data, onAdd }: Props) { const { navigate } = useContext(TenantsContext); const createTenant = useCallback(async () => { - onAdd(await api.post('api/tenants').json()); + onAdd( + /** + * `name` and `tag` are required for POST /tenants API, add fixed value to avoid throwing error. + * This page page will be removed in upcoming changes on multi-tenancy cloud console. + */ + await api + .post('api/tenants', { json: { name: 'My Project', tag: TenantTag.Development } }) + .json() + ); }, [api, onAdd]); useEffect(() => { diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx index 950298f29..9d6d4a625 100644 --- a/packages/console/src/contexts/TenantsProvider.tsx +++ b/packages/console/src/contexts/TenantsProvider.tsx @@ -1,5 +1,4 @@ -import type { TenantInfo } from '@logto/schemas'; -import { defaultManagementApi } from '@logto/schemas'; +import { type TenantInfo, TenantTag, defaultManagementApi } from '@logto/schemas'; import { conditional, noop } from '@silverhand/essentials'; import type { ReactNode } from 'react'; import { useCallback, useMemo, createContext, useState } from 'react'; @@ -22,7 +21,11 @@ export type Tenants = { }; const { tenantId, indicator } = defaultManagementApi.resource; -const initialTenants = conditional(!isCloud && [{ id: tenantId, indicator }]); +const initialTenants = conditional( + !isCloud && [ + { id: tenantId, name: `tenant_${tenantId}`, tag: `${TenantTag.Development}`, indicator }, // Make `tag` value to be string type. + ] +); export const TenantsContext = createContext({ tenants: initialTenants, diff --git a/packages/integration-tests/src/api/tenant.ts b/packages/integration-tests/src/api/tenant.ts index 934851352..4414c186c 100644 --- a/packages/integration-tests/src/api/tenant.ts +++ b/packages/integration-tests/src/api/tenant.ts @@ -1,13 +1,16 @@ -import type { TenantInfo } from '@logto/schemas'; +import type { CreateTenant, TenantInfo } from '@logto/schemas'; import { cloudApi } from './api.js'; -export const createTenant = async (accessToken: string) => { +export const createTenant = async ( + accessToken: string, + payload: Required> +) => { return cloudApi .extend({ headers: { authorization: `Bearer ${accessToken}` }, }) - .post('tenants') + .post('tenants', { json: payload }) .json(); }; diff --git a/packages/integration-tests/src/tests/api-cloud/tenant.test.ts b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts index b33d72608..24bab6ff7 100644 --- a/packages/integration-tests/src/tests/api-cloud/tenant.test.ts +++ b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts @@ -5,6 +5,9 @@ import { type Resource, type Scope, type Role, + TenantTag, + type TenantInfo, + type CreateTenant, } from '@logto/schemas'; import { authedAdminTenantApi } from '#src/api/api.js'; @@ -15,25 +18,55 @@ describe('Tenant APIs', () => { it('should be able to create multiple tenants for `admin` role', async () => { const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.Admin); const accessToken = await client.getAccessToken(cloudApiIndicator); - const tenant1 = await createTenant(accessToken); - const tenant2 = await createTenant(accessToken); - expect(tenant1).toHaveProperty('id'); - expect(tenant2).toHaveProperty('id'); + const payload1 = { + name: 'tenant1', + tag: TenantTag.Staging, + }; + const tenant1 = await createTenant(accessToken, payload1); + const payload2 = { + name: 'tenant2', + tag: TenantTag.Production, + }; + const tenant2 = await createTenant(accessToken, payload2); + for (const [payload, tenant] of [ + [payload1, tenant1], + [payload2, tenant2], + ] as Array<[Required>, TenantInfo]>) { + expect(tenant).toHaveProperty('id'); + expect(tenant).toHaveProperty('tag', payload.tag); + expect(tenant).toHaveProperty('name', payload.name); + } const tenants = await getTenants(accessToken); expect(tenants.length).toBeGreaterThan(2); - expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined(); - expect(tenants.find((tenant) => tenant.id === tenant2.id)).toBeDefined(); + expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1); + expect(tenants.find((tenant) => tenant.id === tenant2.id)).toStrictEqual(tenant2); }); - it('should create only one tenant for `user` role', async () => { + it('should be able to create multiple tenants for `user` role', async () => { const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.User); const accessToken = await client.getAccessToken(cloudApiIndicator); - const tenant1 = await createTenant(accessToken); - await expect(createTenant(accessToken)).rejects.toThrow(); - expect(tenant1).toHaveProperty('id'); + const payload1 = { + name: 'tenant1', + tag: TenantTag.Staging, + }; + const tenant1 = await createTenant(accessToken, payload1); + const payload2 = { + name: 'tenant2', + tag: TenantTag.Development, + }; + const tenant2 = await createTenant(accessToken, payload2); + for (const [payload, tenant] of [ + [payload1, tenant1], + [payload2, tenant2], + ] as Array<[Required>, TenantInfo]>) { + expect(tenant).toHaveProperty('id'); + expect(tenant).toHaveProperty('tag', payload.tag); + expect(tenant).toHaveProperty('name', payload.name); + } const tenants = await getTenants(accessToken); - expect(tenants.length).toEqual(1); - expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined(); + expect(tenants.length).toEqual(2); + expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1); + expect(tenants.find((tenant) => tenant.id === tenant2.id)).toStrictEqual(tenant2); }); it('`user` role should have `CloudScope.ManageTenantSelf` scope', async () => { @@ -43,14 +76,16 @@ describe('Tenant APIs', () => { const scopes = await authedAdminTenantApi .get(`resources/${cloudApiResource!.id}/scopes`) .json(); - const manageOwnTenantScope = scopes.find((scope) => scope.name === CloudScope.ManageTenantSelf); - expect(manageOwnTenantScope).toBeDefined(); + const manageTenantSelfScope = scopes.find( + (scope) => scope.name === CloudScope.ManageTenantSelf + ); + expect(manageTenantSelfScope).toBeDefined(); const roles = await authedAdminTenantApi.get('roles').json(); const userRole = roles.find(({ name }) => name === 'user'); expect(userRole).toBeDefined(); const roleScopes = await authedAdminTenantApi .get(`roles/${userRole!.id}/scopes`) .json(); - expect(roleScopes.find(({ id }) => id === manageOwnTenantScope!.id)).toBeDefined(); + expect(roleScopes.find(({ id }) => id === manageTenantSelfScope!.id)).toBeDefined(); }); }); diff --git a/packages/schemas/src/seeds/tenant.ts b/packages/schemas/src/seeds/tenant.ts index 8eb4389b1..b87dc0cb5 100644 --- a/packages/schemas/src/seeds/tenant.ts +++ b/packages/schemas/src/seeds/tenant.ts @@ -10,5 +10,3 @@ export const adminTenantId = 'admin'; * type, manually define it here for now. */ export type TenantModel = InferModelType; -export type CreateTenant = Pick & - Partial>; diff --git a/packages/schemas/src/types/tenant.ts b/packages/schemas/src/types/tenant.ts index 7e6bd4172..984e025ca 100644 --- a/packages/schemas/src/types/tenant.ts +++ b/packages/schemas/src/types/tenant.ts @@ -1,10 +1,29 @@ +import { z } from 'zod'; + +import { type TenantModel } from '../seeds/tenant.js'; + export enum TenantTag { Development = 'development', Staging = 'staging', Production = 'production', } -export type TenantInfo = { - id: string; - indicator: string; -}; +export type PatchTenant = Partial>; +export type CreateTenant = Pick & + PatchTenant & { createdAt?: number }; + +export const createTenantGuard = z.object({ + id: z.string(), + dbUser: z.string(), + dbUserPassword: z.string(), + name: z.string().optional(), + tag: z.nativeEnum(TenantTag).optional(), + createdAt: z.number().optional(), +}); + +export type TenantInfo = Pick & { indicator: string }; + +export const tenantInfoGuard: z.ZodType = createTenantGuard + .pick({ id: true, name: true, tag: true }) + .extend({ indicator: z.string() }) + .required();