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:
parent
47abbd8cb6
commit
1b57f26533
10 changed files with 283 additions and 14 deletions
2
.github/workflows/integration-test.yml
vendored
2
.github/workflows/integration-test.yml
vendored
|
@ -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' || '' }}
|
||||
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue