mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
refactor(core,schemas): remove cloudConnection call in koaTenantGuard (#5395)
remove cloudConnection dependency in the koaTenantGuard
This commit is contained in:
parent
935ee34e12
commit
52f4e578a5
7 changed files with 97 additions and 56 deletions
|
@ -1,35 +1,30 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import Client from '@withtyped/client';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import koaTenantGuard from './koa-tenant-guard.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const logtoConfigs: LogtoConfigLibrary = {
|
||||
getCloudConnectionData: jest.fn().mockResolvedValue({
|
||||
appId: 'appId',
|
||||
appSecret: 'appSecret',
|
||||
resource: 'resource',
|
||||
}),
|
||||
getOidcConfigs: jest.fn(),
|
||||
};
|
||||
const mockFindTenantStatusById = jest.fn();
|
||||
|
||||
const queries = new MockQueries({
|
||||
tenants: {
|
||||
findTenantSuspendStatusById: mockFindTenantStatusById,
|
||||
},
|
||||
});
|
||||
|
||||
describe('koaTenantGuard middleware', () => {
|
||||
const cloudConnection = new CloudConnectionLibrary(logtoConfigs);
|
||||
const mockCloudClient = new Client<typeof router>({ baseUrl: 'http://localhost:3000' });
|
||||
|
||||
const getClientSpy = jest.spyOn(cloudConnection, 'getClient').mockResolvedValue(mockCloudClient);
|
||||
const clientGetSpy = jest.spyOn(mockCloudClient, 'get');
|
||||
|
||||
const next = jest.fn();
|
||||
const ctx = createContextWithRouteParameters();
|
||||
const tenantId = 'tenant_id';
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return directly if not in cloud', async () => {
|
||||
const stub = Sinon.stub(EnvSet, 'values').value({
|
||||
|
@ -37,48 +32,31 @@ describe('koaTenantGuard middleware', () => {
|
|||
isCloud: false,
|
||||
});
|
||||
|
||||
await expect(koaTenantGuard(cloudConnection)(ctx, next)).resolves.not.toThrow();
|
||||
expect(clientGetSpy).not.toBeCalled();
|
||||
expect(getClientSpy).not.toBeCalled();
|
||||
|
||||
await expect(koaTenantGuard(tenantId, queries)(ctx, next)).resolves.not.toThrow();
|
||||
expect(mockFindTenantStatusById).not.toBeCalled();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
it('should reject if tenant is suspended', async () => {
|
||||
const stub = Sinon.stub(EnvSet, 'values').value({
|
||||
Sinon.stub(EnvSet, 'values').value({
|
||||
...EnvSet.values,
|
||||
isCloud: true,
|
||||
});
|
||||
|
||||
// @ts-expect-error mock returning value
|
||||
clientGetSpy.mockResolvedValue({
|
||||
isSuspended: true,
|
||||
});
|
||||
mockFindTenantStatusById.mockResolvedValueOnce({ isSuspended: true });
|
||||
|
||||
await expect(koaTenantGuard(cloudConnection)(ctx, next)).rejects.toMatchError(
|
||||
await expect(koaTenantGuard(tenantId, queries)(ctx, next)).rejects.toMatchError(
|
||||
new RequestError('subscription.tenant_suspended', 403)
|
||||
);
|
||||
|
||||
expect(clientGetSpy).toBeCalledWith('/api/my/tenant');
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
it('should resolve if tenant is not suspended', async () => {
|
||||
const stub = Sinon.stub(EnvSet, 'values').value({
|
||||
Sinon.stub(EnvSet, 'values').value({
|
||||
...EnvSet.values,
|
||||
isCloud: true,
|
||||
});
|
||||
|
||||
// @ts-expect-error mock returning value
|
||||
clientGetSpy.mockResolvedValue({
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
await expect(koaTenantGuard(cloudConnection)(ctx, next)).resolves.not.toThrow();
|
||||
|
||||
expect(clientGetSpy).toBeCalledWith('/api/my/tenant');
|
||||
|
||||
stub.restore();
|
||||
mockFindTenantStatusById.mockResolvedValueOnce({ tenantId, isSuspended: false });
|
||||
await expect(koaTenantGuard(tenantId, queries)(ctx, next)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,17 +3,11 @@ import { type IRouterParamContext } from 'koa-router';
|
|||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||
|
||||
const getAvailableTenant = async (cloudConnection: CloudConnectionLibrary) => {
|
||||
const client = await cloudConnection.getClient();
|
||||
const tenant = await client.get('/api/my/tenant');
|
||||
|
||||
return tenant;
|
||||
};
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
export default function koaTenantGuard<StateT, ContextT extends IRouterParamContext, BodyT>(
|
||||
cloudConnection: CloudConnectionLibrary
|
||||
tenantId: string,
|
||||
{ tenants }: Queries
|
||||
): Middleware<StateT, ContextT, BodyT> {
|
||||
return async (ctx, next) => {
|
||||
const { isCloud } = EnvSet.values;
|
||||
|
@ -22,9 +16,9 @@ export default function koaTenantGuard<StateT, ContextT extends IRouterParamCont
|
|||
return next();
|
||||
}
|
||||
|
||||
const tenant = await getAvailableTenant(cloudConnection);
|
||||
const { isSuspended } = await tenants.findTenantSuspendStatusById(tenantId);
|
||||
|
||||
if (tenant.isSuspended) {
|
||||
if (isSuspended) {
|
||||
throw new RequestError('subscription.tenant_suspended', 403);
|
||||
}
|
||||
|
||||
|
|
28
packages/core/src/queries/tenant.ts
Normal file
28
packages/core/src/queries/tenant.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Tenants, type TenantModel } from '@logto/schemas/models';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { sql, type CommonQueryMethods } from 'slonik';
|
||||
|
||||
const createTenantQueries = (pool: CommonQueryMethods) => {
|
||||
const { table, fields } = convertToIdentifiers({
|
||||
table: Tenants.tableName,
|
||||
fields: Tenants.rawKeys,
|
||||
});
|
||||
|
||||
const findTenantSuspendStatusById = async (
|
||||
id: string
|
||||
): Promise<Pick<TenantModel, 'id' | 'isSuspended'>> => {
|
||||
const result = await pool.one<Pick<TenantModel, 'id' | 'isSuspended'>>(sql`
|
||||
select ${sql.join([fields.id, fields.isSuspended], sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.id} = ${id}
|
||||
`);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
findTenantSuspendStatusById,
|
||||
};
|
||||
};
|
||||
|
||||
export default createTenantQueries;
|
|
@ -46,7 +46,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
|
||||
const managementRouter: AuthedRouter = new Router();
|
||||
managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id)));
|
||||
managementRouter.use(koaTenantGuard(tenant.cloudConnection));
|
||||
managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
|
||||
|
||||
applicationRoutes(managementRouter, tenant);
|
||||
applicationRoleRoutes(managementRouter, tenant);
|
||||
|
|
|
@ -21,6 +21,7 @@ import { createRolesQueries } from '#src/queries/roles.js';
|
|||
import { createScopeQueries } from '#src/queries/scope.js';
|
||||
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
|
||||
import SsoConnectorQueries from '#src/queries/sso-connectors.js';
|
||||
import createTenantQueries from '#src/queries/tenant.js';
|
||||
import UserSsoIdentityQueries from '#src/queries/user-sso-identities.js';
|
||||
import { createUserQueries } from '#src/queries/user.js';
|
||||
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||
|
@ -51,6 +52,7 @@ export default class Queries {
|
|||
organizations = new OrganizationQueries(this.pool);
|
||||
ssoConnectors = new SsoConnectorQueries(this.pool);
|
||||
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
||||
tenants = createTenantQueries(this.pool);
|
||||
|
||||
constructor(
|
||||
public readonly pool: CommonQueryMethods,
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import { type CommonQueryMethods, sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const getDatabaseName = async (pool: CommonQueryMethods) => {
|
||||
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
|
||||
select current_database();
|
||||
`);
|
||||
|
||||
return currentDatabase.replaceAll('-', '_');
|
||||
};
|
||||
|
||||
/**
|
||||
* Grant read permission to the is_suspended column in the tenants table to the logto_tenant_<databaseName> role.
|
||||
*/
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
const databaseName = await getDatabaseName(pool);
|
||||
const baseRoleId = sql.identifier([`logto_tenant_${databaseName}`]);
|
||||
|
||||
await pool.query(sql`
|
||||
grant select (is_suspended)
|
||||
on table tenants
|
||||
to ${baseRoleId}
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
const databaseName = await getDatabaseName(pool);
|
||||
const baseRoleId = sql.identifier([`logto_tenant_${databaseName}`]);
|
||||
|
||||
await pool.query(sql`
|
||||
revoke select(is_suspended)
|
||||
on table tenants
|
||||
from ${baseRoleId}
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -13,7 +13,7 @@ revoke all privileges
|
|||
from logto_tenant_${database};
|
||||
|
||||
-- Allow limited select to perform the RLS policy query in `after_each` (using select ... from tenants ...)
|
||||
grant select (id, db_user)
|
||||
grant select (id, db_user, is_suspended)
|
||||
on table tenants
|
||||
to logto_tenant_${database};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue