0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat(cloud,schemas): add PATCH /tenants/:id API (#3881)

This commit is contained in:
Darcy Ye 2023-05-30 00:54:52 +08:00 committed by GitHub
parent bb77850e62
commit 47abbd8cb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 201 additions and 64 deletions

View file

@ -20,6 +20,7 @@ import {
createAdminData,
createAdminDataInAdminTenant,
getManagementApiResourceIndicator,
type PatchTenant,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { appendPath } from '@silverhand/essentials';
@ -70,6 +71,12 @@ export class TenantsLibrary {
}));
}
async updateTenantById(tenantId: string, payload: PatchTenant): Promise<TenantInfo> {
const { id, name, tag } = await this.queries.tenants.updateTenantById(tenantId, payload);
return { id, name, tag, indicator: getManagementApiResourceIndicator(id) };
}
async createNewTenant(
forUserId: string,
payload: Pick<CreateTenant, 'name' | 'tag'>

View file

@ -7,8 +7,10 @@ import {
PredefinedScope,
type AdminData,
type CreateTenant,
type PatchTenant,
type CreateRolesScope,
type TenantInfo,
type TenantModel,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import type { PostgreSql } from '@withtyped/postgres';
@ -46,6 +48,25 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
)
);
const updateTenantById = async (tenantId: string, rawPayload: PatchTenant) => {
const payload: Record<string, string> = Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Object.entries(rawPayload).filter(([_, value]) => value !== undefined)
);
const tenant = await client.maybeOne<TenantModel>(sql`
update tenants
set ${Object.entries(payload).map(([key, value]) => sql`${id(key)}=${jsonIfNeeded(value)}`)}
where id = ${tenantId}
returning *;
`);
if (!tenant) {
throw new Error(`Tenant ${tenantId} not found.`);
}
return tenant;
};
const createTenantRole = async (parentRole: string, role: string, password: string) =>
client.query(sql`
create role ${id(role)} with inherit login
@ -129,6 +150,7 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
return {
getManagementApiLikeIndicatorsForUser,
insertTenant,
updateTenantById,
createTenantRole,
insertAdminData,
getTenantById,

View file

@ -56,74 +56,110 @@ describe('POST /api/tenants', () => {
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
library.getAvailableTenants.mockResolvedValueOnce([]);
library.createNewTenant.mockResolvedValueOnce(tenant);
library.createNewTenant.mockImplementationOnce(async (_, payload) => {
return { ...tenant, ...payload };
});
await router.routes()(
buildRequestAuthContext('POST /tenants', {
body: { name: 'tenant_a', tag: TenantTag.Development },
body: { name: 'tenant_named', tag: TenantTag.Staging },
})([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',
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.ManageTenant]),
async ({ json, status }) => {
expect(json).toBe(tenantB);
expect(json).toStrictEqual({ ...tenant, name: 'tenant_named', tag: TenantTag.Staging });
expect(status).toBe(201);
},
createHttpContext()
);
});
});
describe('PATCH /api/tenants/:tenantId', () => {
const library = new MockTenantsLibrary();
const router = tenantsRoutes(library);
it('should throw 403 when lack of permission', async () => {
// Library.getAvailableTenants.mockResolvedValueOnce([]);
await expect(
router.routes()(
buildRequestAuthContext('PATCH /tenants/tenant_a', { body: {} })(),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should throw 404 operating unavailable tenants', async () => {
const tenant: TenantInfo = {
id: 'tenant_a',
name: 'tenant_a',
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
await expect(
router.routes()(
buildRequestAuthContext('PATCH /tenants/tenant_b', { body: {} })([
CloudScope.ManageTenantSelf,
]),
noop,
createHttpContext()
)
).rejects.toThrow();
});
it('should be able to update arbitrary tenant with `ManageTenant` scope', async () => {
const tenant: TenantInfo = {
id: 'tenant_a',
name: 'tenant_a',
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
// Library.getAvailableTenants.mockResolvedValueOnce([]);
library.updateTenantById.mockImplementationOnce(async (_, payload): Promise<TenantInfo> => {
return { ...tenant, ...payload };
});
await router.routes()(
buildRequestAuthContext('PATCH /tenants/tenant_a', {
body: {
name: 'tenant_b',
tag: TenantTag.Staging,
},
})([CloudScope.ManageTenant]),
async ({ json, status }) => {
expect(json).toStrictEqual({ ...tenant, name: 'tenant_b', tag: TenantTag.Staging });
expect(status).toBe(200);
},
createHttpContext()
);
});
it('should be able to update available tenant with `ManageTenantSelf` scope', async () => {
const tenant: TenantInfo = {
id: 'tenant_a',
name: 'tenant_a',
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
library.updateTenantById.mockImplementationOnce(async (_, payload): Promise<TenantInfo> => {
return { ...tenant, ...payload };
});
await router.routes()(
buildRequestAuthContext('PATCH /tenants/tenant_a', {
body: {
name: 'tenant_b',
tag: TenantTag.Staging,
},
})([CloudScope.ManageTenant]),
async ({ json, status }) => {
expect(json).toStrictEqual({ ...tenant, name: 'tenant_b', tag: TenantTag.Staging });
expect(status).toBe(200);
},
createHttpContext()
);
});
});

View file

@ -1,4 +1,5 @@
import { CloudScope, tenantInfoGuard, createTenantGuard } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { createRouter, RequestError } from '@withtyped/server';
import type { TenantsLibrary } from '#src/libraries/tenants.js';
@ -13,6 +14,44 @@ export const tenantsRoutes = (library: TenantsLibrary) =>
status: 200,
});
})
.patch(
'/:tenantId',
{
body: createTenantGuard.pick({ name: true, tag: true }).partial(),
response: tenantInfoGuard,
},
async (context, next) => {
/** Users w/o either `ManageTenant` or `ManageTenantSelf` scope does not have permission. */
if (
![CloudScope.ManageTenant, CloudScope.ManageTenantSelf].some((scope) =>
context.auth.scopes.includes(scope)
)
) {
throw new RequestError('Forbidden due to lack of permission.', 403);
}
/** Should throw 404 when users with `ManageTenantSelf` scope are attempting to change an unavailable tenant. */
if (!context.auth.scopes.includes(CloudScope.ManageTenant)) {
const availableTenants = await library.getAvailableTenants(context.auth.id);
assert(
availableTenants.map(({ id }) => id).includes(context.guarded.params.tenantId),
new RequestError(
`Can not find tenant whose id is '${context.guarded.params.tenantId}'.`,
404
)
);
}
return next({
...context,
json: await library.updateTenantById(
context.guarded.params.tenantId,
context.guarded.body
),
status: 200,
});
}
)
.post(
'/',
{

View file

@ -1,4 +1,4 @@
import type { ServiceLogType, TenantInfo } from '@logto/schemas';
import type { ServiceLogType, TenantInfo, TenantTag } from '@logto/schemas';
import type { ServicesLibrary } from '#src/libraries/services.js';
import type { TenantsLibrary } from '#src/libraries/tenants.js';
@ -13,6 +13,10 @@ export class MockTenantsLibrary implements TenantsLibrary {
public getAvailableTenants = jest.fn<Promise<TenantInfo[]>, [string]>();
public createNewTenant = jest.fn<Promise<TenantInfo>, [string, Record<string, unknown>]>();
public updateTenantById = jest.fn<
Promise<TenantInfo>,
[string, { name?: string; tag?: TenantTag }]
>();
}
export class MockServicesLibrary implements ServicesLibrary {

View file

@ -1,4 +1,4 @@
import type { CreateTenant, TenantInfo } from '@logto/schemas';
import type { CreateTenant, TenantInfo, TenantTag } from '@logto/schemas';
import { cloudApi } from './api.js';
@ -22,3 +22,16 @@ export const getTenants = async (accessToken: string) => {
.get('tenants')
.json<TenantInfo[]>();
};
export const updateTenant = async (
accessToken: string,
tenantId: string,
payload: { name?: string; tag?: TenantTag }
) => {
return cloudApi
.extend({
headers: { authorization: `Bearer ${accessToken}` },
})
.patch(`tenants/${tenantId}`, { json: payload })
.json<TenantInfo>();
};

View file

@ -11,7 +11,7 @@ import {
} from '@logto/schemas';
import { authedAdminTenantApi } from '#src/api/api.js';
import { createTenant, getTenants } from '#src/api/tenant.js';
import { updateTenant, createTenant, getTenants } from '#src/api/tenant.js';
import { createUserAndSignInToCloudClient } from '#src/helpers/admin-tenant.js';
describe('Tenant APIs', () => {
@ -36,10 +36,17 @@ describe('Tenant APIs', () => {
expect(tenant).toHaveProperty('tag', payload.tag);
expect(tenant).toHaveProperty('name', payload.name);
}
const tenant2Updated = await updateTenant(accessToken, tenant2.id, {
tag: TenantTag.Staging,
name: 'tenant2-updated',
});
expect(tenant2Updated.id).toEqual(tenant2.id);
expect(tenant2Updated).toHaveProperty('tag', TenantTag.Staging);
expect(tenant2Updated).toHaveProperty('name', 'tenant2-updated');
const tenants = await getTenants(accessToken);
expect(tenants.length).toBeGreaterThan(2);
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1);
expect(tenants.find((tenant) => tenant.id === tenant2.id)).toStrictEqual(tenant2);
expect(tenants.find((tenant) => tenant.id === tenant2Updated.id)).toStrictEqual(tenant2Updated);
});
it('should be able to create multiple tenants for `user` role', async () => {
@ -67,6 +74,15 @@ describe('Tenant APIs', () => {
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);
const { client: anotherClient } = await createUserAndSignInToCloudClient(AdminTenantRole.User);
const anotherAccessToken = await anotherClient.getAccessToken(cloudApiIndicator);
const anotherTenant = await createTenant(anotherAccessToken, {
name: 'another-tenant',
tag: TenantTag.Development,
});
await expect(
updateTenant(accessToken, anotherTenant.id, { name: 'another-tenant-updated' })
).rejects.toThrow();
});
it('`user` role should have `CloudScope.ManageTenantSelf` scope', async () => {