0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(cloud,schemas,cli): update cloud service tenant APIs (#3875)

This commit is contained in:
Darcy Ye 2023-05-29 12:57:10 +08:00 committed by GitHub
parent 708303349e
commit 62b1ee6b72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 265 additions and 91 deletions

View file

@ -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<TenantInfo> = z.object({
id: z.string(),
indicator: z.string(),
});
const oidcConfigBuilders: {
[key in LogtoOidcConfigKey]: () => Promise<LogtoOidcConfigType[key]>;
} = {
@ -54,23 +50,40 @@ export class TenantsLibrary {
constructor(public readonly queries: Queries) {}
async getAvailableTenants(userId: string): Promise<TenantInfo[]> {
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 [];
}
async createNewTenant(forUserId: string): Promise<TenantInfo> {
const rawTenantInfos = await getTenantsByIds(tenantIds);
return rawTenantInfos.map(({ id, name, tag }) => ({
id,
name,
tag,
indicator: getManagementApiResourceIndicator(id),
}));
}
async createNewTenant(
forUserId: string,
payload: Pick<CreateTenant, 'name' | 'tag'>
): Promise<TenantInfo> {
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,
};
}
}

View file

@ -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<PostgreSql>) => {
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<PostgreSql>) => {
);
};
const getTenantById = async (id: string): Promise<Pick<TenantInfo, 'name' | 'tag'>> => {
return client.one<Pick<TenantInfo, 'name' | 'tag'>>(sql`
select name, tag from tenants
where id = ${id}
`);
};
const getTenantsByIds = async (
tenantIds: string[]
): Promise<Array<Pick<TenantInfo, 'id' | 'name' | 'tag'>>> => {
const { rows } = await client.query<Pick<TenantInfo, 'id' | 'name' | 'tag'>>(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<PostgreSql>) => {
insertTenant,
createTenantRole,
insertAdminData,
getTenantById,
getTenantsByIds,
appendAdminConsoleRedirectUris,
};
};

View file

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

View file

@ -1,16 +1,25 @@
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<WithAuthContext, '/tenants'>('/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) => {
.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)
@ -19,11 +28,10 @@ export const tenantsRoutes = (library: TenantsLibrary) =>
throw new RequestError('Forbidden due to lack of permission.', 403);
}
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) });
return next({
...context,
json: await library.createNewTenant(context.auth.id, context.guarded.body),
status: 201,
});
}
);

View file

@ -12,7 +12,7 @@ export class MockTenantsLibrary implements TenantsLibrary {
}
public getAvailableTenants = jest.fn<Promise<TenantInfo[]>, [string]>();
public createNewTenant = jest.fn<Promise<TenantInfo>, [string]>();
public createNewTenant = jest.fn<Promise<TenantInfo>, [string, Record<string, unknown>]>();
}
export class MockServicesLibrary implements ServicesLibrary {

View file

@ -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<TenantInfo>());
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<TenantInfo>()
);
}, [api, onAdd]);
useEffect(() => {

View file

@ -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>({
tenants: initialTenants,

View file

@ -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<Pick<CreateTenant, 'name' | 'tag'>>
) => {
return cloudApi
.extend({
headers: { authorization: `Bearer ${accessToken}` },
})
.post('tenants')
.post('tenants', { json: payload })
.json<TenantInfo>();
};

View file

@ -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<Pick<CreateTenant, 'name' | 'tag'>>, 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<Pick<CreateTenant, 'name' | 'tag'>>, 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<Scope[]>();
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<Role[]>();
const userRole = roles.find(({ name }) => name === 'user');
expect(userRole).toBeDefined();
const roleScopes = await authedAdminTenantApi
.get(`roles/${userRole!.id}/scopes`)
.json<Scope[]>();
expect(roleScopes.find(({ id }) => id === manageOwnTenantScope!.id)).toBeDefined();
expect(roleScopes.find(({ id }) => id === manageTenantSelfScope!.id)).toBeDefined();
});
});

View file

@ -10,5 +10,3 @@ export const adminTenantId = 'admin';
* type, manually define it here for now.
*/
export type TenantModel = InferModelType<typeof Tenants>;
export type CreateTenant = Pick<TenantModel, 'id' | 'dbUser' | 'dbUserPassword'> &
Partial<Pick<TenantModel, 'name'>>;

View file

@ -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<Pick<TenantModel, 'name' | 'tag'>>;
export type CreateTenant = Pick<TenantModel, 'id' | 'dbUser' | 'dbUserPassword'> &
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<TenantModel, 'id' | 'name' | 'tag'> & { indicator: string };
export const tenantInfoGuard: z.ZodType<TenantInfo> = createTenantGuard
.pick({ id: true, name: true, tag: true })
.extend({ indicator: z.string() })
.required();