0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(cloud,test): add DELETE /tenants/:id API (#3900)

This commit is contained in:
Darcy Ye 2023-05-30 10:50:06 +08:00 committed by GitHub
parent 47abbd8cb6
commit 1b57f26533
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 283 additions and 14 deletions

View file

@ -62,6 +62,7 @@ jobs:
INTEGRATION_TEST: true
IS_CLOUD: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
PATH_BASED_MULTI_TENANCY: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v3
@ -106,7 +107,6 @@ jobs:
run: |
npm run cli init -- \
-p ../logto \
--db postgres://postgres:postgres@localhost:5432/postgres \
--du ../logto.tar.gz \
${{ contains(matrix.target, 'cloud') && '--cloud' || '' }}

View file

@ -77,6 +77,46 @@ export class TenantsLibrary {
return { id, name, tag, indicator: getManagementApiResourceIndicator(id) };
}
async deleteTenantById(tenantId: string) {
const {
deleteTenantById,
deleteDatabaseRoleForTenant,
deleteClientTenantManagementApiResourceByTenantId,
deleteClientTenantRoleById,
getTenantById,
deleteClientTenantManagementApplicationById,
removeUrisFromAdminConsoleRedirectUris,
} = this.queries.tenants;
const { cloudUrlSet } = EnvSet.global;
/** `dbUser` is defined as nullable but we always specified this value when creating a new tenant. */
const { dbUser } = await getTenantById(tenantId);
if (dbUser) {
/** DB role for building connection for the current tenant. */
await deleteDatabaseRoleForTenant(dbUser);
}
await deleteTenantById(tenantId);
/**
* All applications, resources, scopes and roles attached to the current tenant
* will be deleted per DB design.
* Need to manually delete following applications, roles, resources since they
* are created for admin tenant which will not be deleted automatically.
*/
/** Delete management API for the current tenant. */
/** `scopes` will be automatically deleted if its related resources have been removed. */
await deleteClientTenantManagementApiResourceByTenantId(tenantId);
/** Delete admin tenant admin role for the current tenant. */
await deleteClientTenantRoleById(tenantId);
/** Delete M2M application for the current principal (tenant, characterized by `tenantId`). */
await deleteClientTenantManagementApplicationById(tenantId);
await removeUrisFromAdminConsoleRedirectUris(
...cloudUrlSet.deduplicated().map((url) => appendPath(url, tenantId, 'callback'))
);
}
async createNewTenant(
forUserId: string,
payload: Pick<CreateTenant, 'name' | 'tag'>

View file

@ -4,17 +4,25 @@ import {
adminConsoleApplicationId,
adminTenantId,
getManagementApiResourceIndicator,
getManagementApiAdminName,
PredefinedScope,
ApplicationType,
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';
import { jsonb, dangerousRaw, id, sql, jsonIfNeeded } from '@withtyped/postgres';
import {
type PostgreSql,
jsonb,
dangerousRaw,
id,
sql,
jsonIfNeeded,
jsonbIfNeeded,
} from '@withtyped/postgres';
import type { Queryable } from '@withtyped/server';
import { insertInto } from '#src/utils/query.js';
@ -74,6 +82,9 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
in role ${id(parentRole)};
`);
const deleteDatabaseRoleForTenant = async (role: string) =>
client.query(sql`drop role ${id(role)};`);
const insertAdminData = async (data: AdminData) => {
const { resource, scopes, role } = data;
@ -111,17 +122,19 @@ 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
const getTenantById = async (
id: string
): Promise<Pick<TenantModel, 'dbUser' | 'name' | 'tag'>> => {
return client.one<Pick<TenantModel, 'dbUser' | 'name' | 'tag'>>(sql`
select db_user as "dbUser", 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`
): Promise<Array<Pick<TenantModel, 'id' | 'name' | 'tag'>>> => {
const { rows } = await client.query<Pick<TenantModel, '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;
@ -130,6 +143,35 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
return rows;
};
const deleteClientTenantManagementApplicationById = async (tenantId: string) => {
await client.query(sql`
delete from applications where custom_client_metadata->>'tenantId' = ${tenantId} and tenant_id = ${adminTenantId} and "type" = ${ApplicationType.MachineToMachine}
`);
};
const deleteClientTenantManagementApiResourceByTenantId = async (tenantId: string) => {
await client.query(sql`
delete from resources
where tenant_id = ${adminTenantId} and indicator = ${getManagementApiResourceIndicator(
tenantId
)}
`);
};
const deleteClientTenantRoleById = async (tenantId: string) => {
await client.query(sql`
delete from roles
where tenant_id = ${adminTenantId} and name = ${getManagementApiAdminName(tenantId)}
`);
};
const deleteTenantById = async (id: string) => {
await client.query(sql`
delete from tenants
where id = ${id}
`);
};
const appendAdminConsoleRedirectUris = async (...urls: URL[]) => {
const metadataKey = id('oidc_client_metadata');
@ -147,14 +189,39 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
`);
};
const removeUrisFromAdminConsoleRedirectUris = async (...urls: URL[]) => {
const metadataKey = id('oidc_client_metadata');
const { redirectUris } = await client.one<{ redirectUris: string[] }>(
sql`select ${metadataKey}->'redirectUris' as "redirectUris" from applications where id = ${adminConsoleApplicationId} and tenant_id = ${adminTenantId}`
);
const restRedirectUris = redirectUris.filter(
(redirectUri) => !urls.map(String).includes(redirectUri)
);
await client.query(sql`
update applications
set ${metadataKey} = jsonb_set(${metadataKey}, '{redirectUris}', ${jsonbIfNeeded(
restRedirectUris
)})
where id = ${adminConsoleApplicationId} and tenant_id = ${adminTenantId};
`);
};
return {
getManagementApiLikeIndicatorsForUser,
insertTenant,
updateTenantById,
createTenantRole,
deleteDatabaseRoleForTenant,
insertAdminData,
getTenantById,
getTenantsByIds,
deleteClientTenantManagementApplicationById,
deleteClientTenantManagementApiResourceByTenantId,
deleteClientTenantRoleById,
deleteTenantById,
appendAdminConsoleRedirectUris,
removeUrisFromAdminConsoleRedirectUris,
};
};

View file

@ -78,8 +78,6 @@ describe('PATCH /api/tenants/:tenantId', () => {
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: {} })(),
@ -116,7 +114,6 @@ describe('PATCH /api/tenants/:tenantId', () => {
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
// Library.getAvailableTenants.mockResolvedValueOnce([]);
library.updateTenantById.mockImplementationOnce(async (_, payload): Promise<TenantInfo> => {
return { ...tenant, ...payload };
});
@ -163,3 +160,77 @@ describe('PATCH /api/tenants/:tenantId', () => {
);
});
});
describe('DELETE /api/tenants/:tenantId', () => {
const library = new MockTenantsLibrary();
const router = tenantsRoutes(library);
it('should throw 403 when lack of permission', async () => {
await expect(
router.routes()(
buildRequestAuthContext('DELETE /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('DELETE /tenants/tenant_b', { body: {} })([
CloudScope.ManageTenantSelf,
]),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 404 });
});
it('should be able to delete arbitrary tenant with `ManageTenant` scope', async () => {
const tenant: TenantInfo = {
id: 'tenant_a',
name: 'tenant_a',
tag: TenantTag.Development,
indicator: 'https://foo.bar',
};
library.deleteTenantById.mockResolvedValueOnce();
await router.routes()(
buildRequestAuthContext(`DELETE /tenants/${tenant.id}`)([CloudScope.ManageTenant]),
async ({ json, status }) => {
expect(json).toBeUndefined();
expect(status).toBe(204);
},
createHttpContext()
);
});
it('should be able to delete 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.deleteTenantById.mockResolvedValueOnce();
await router.routes()(
buildRequestAuthContext(`DELETE /tenants/${tenant.id}`)([CloudScope.ManageTenant]),
async ({ json, status }) => {
expect(json).toBeUndefined();
expect(status).toBe(204);
},
createHttpContext()
);
});
});

View file

@ -73,4 +73,29 @@ export const tenantsRoutes = (library: TenantsLibrary) =>
status: 201,
});
}
);
)
.delete('/:tenantId', {}, 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
)
);
}
await library.deleteTenantById(context.guarded.params.tenantId);
return next({ ...context, status: 204 });
});

View file

@ -17,6 +17,8 @@ export class MockTenantsLibrary implements TenantsLibrary {
Promise<TenantInfo>,
[string, { name?: string; tag?: TenantTag }]
>();
public deleteTenantById = jest.fn<Promise<void>, [string]>();
}
export class MockServicesLibrary implements ServicesLibrary {

View file

@ -28,6 +28,7 @@
"@logto/js": "^2.0.1",
"@logto/node": "^2.0.0",
"@logto/schemas": "workspace:^1.4.0",
"@logto/shared": "workspace:^2.0.0",
"@silverhand/eslint-config": "3.0.1",
"@silverhand/essentials": "^2.5.0",
"@silverhand/ts-config": "3.0.0",

View file

@ -35,3 +35,12 @@ export const updateTenant = async (
.patch(`tenants/${tenantId}`, { json: payload })
.json<TenantInfo>();
};
export const deleteTenant = async (accessToken: string, tenantId: string) => {
return cloudApi
.extend({
headers: { authorization: `Bearer ${accessToken}` },
})
.delete(`tenants/${tenantId}`)
.json();
};

View file

@ -1,7 +1,13 @@
import {
adminTenantId,
getManagementApiResourceIndicator,
getManagementApiAdminName,
cloudApiIndicator,
CloudScope,
AdminTenantRole,
ApplicationType,
adminConsoleApplicationId,
type Application,
type Resource,
type Scope,
type Role,
@ -9,9 +15,11 @@ import {
type TenantInfo,
type CreateTenant,
} from '@logto/schemas';
import { GlobalValues } from '@logto/shared';
import { appendPath } from '@silverhand/essentials';
import { authedAdminTenantApi } from '#src/api/api.js';
import { updateTenant, createTenant, getTenants } from '#src/api/tenant.js';
import { updateTenant, createTenant, getTenants, deleteTenant } from '#src/api/tenant.js';
import { createUserAndSignInToCloudClient } from '#src/helpers/admin-tenant.js';
describe('Tenant APIs', () => {
@ -62,14 +70,57 @@ describe('Tenant APIs', () => {
tag: TenantTag.Development,
};
const tenant2 = await createTenant(accessToken, payload2);
const payload3 = {
name: 'tenant3',
tag: TenantTag.Production,
};
const tenant3 = await createTenant(accessToken, payload3);
for (const [payload, tenant] of [
[payload1, tenant1],
[payload2, tenant2],
[payload3, tenant3],
] as Array<[Required<Pick<CreateTenant, 'name' | 'tag'>>, TenantInfo]>) {
expect(tenant).toHaveProperty('id');
expect(tenant).toHaveProperty('tag', payload.tag);
expect(tenant).toHaveProperty('name', payload.name);
}
await deleteTenant(accessToken, tenant3.id);
const resources = await authedAdminTenantApi.get('resources').json<Resource[]>();
expect(
resources.filter(
(resource) =>
resource.tenantId === adminTenantId &&
resource.indicator === getManagementApiResourceIndicator(tenant3.id)
).length
).toBe(0);
const roles = await authedAdminTenantApi.get('roles').json<Role[]>();
expect(
roles.filter(
(role) =>
role.tenantId === adminTenantId && role.name === getManagementApiAdminName(tenant3.id)
).length
).toBe(0);
const applications = await authedAdminTenantApi.get('applications').json<Application[]>();
expect(
applications.filter(
(application) =>
application.tenantId === adminTenantId &&
application.type === ApplicationType.MachineToMachine &&
application.customClientMetadata.tenantId === tenant3.id
).length
).toBe(0);
const adminConsoleApplication = applications.find(
(application) =>
application.tenantId === adminTenantId && application.id === adminConsoleApplicationId
);
expect(adminConsoleApplication).toBeDefined();
const urls = new GlobalValues().cloudUrlSet
.deduplicated()
.map((endpoint) => appendPath(endpoint, tenant3.id, 'callback'))
.map(String);
expect(
urls.every((url) => !adminConsoleApplication!.oidcClientMetadata.redirectUris.includes(url))
).toBeTruthy();
const tenants = await getTenants(accessToken);
expect(tenants.length).toEqual(2);
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1);

View file

@ -3405,6 +3405,9 @@ importers:
'@logto/schemas':
specifier: workspace:^1.4.0
version: link:../schemas
'@logto/shared':
specifier: workspace:^2.0.0
version: link:../shared
'@silverhand/eslint-config':
specifier: 3.0.1
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)