0
Fork 0
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:
simeng-li 2024-02-19 10:45:57 +08:00 committed by GitHub
parent 935ee34e12
commit 52f4e578a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 56 deletions

View file

@ -1,35 +1,30 @@
import type router from '@logto/cloud/routes';
import Client from '@withtyped/client';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import { MockQueries } from '#src/test-utils/tenant.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaTenantGuard from './koa-tenant-guard.js'; import koaTenantGuard from './koa-tenant-guard.js';
const { jest } = import.meta; const { jest } = import.meta;
const logtoConfigs: LogtoConfigLibrary = { const mockFindTenantStatusById = jest.fn();
getCloudConnectionData: jest.fn().mockResolvedValue({
appId: 'appId', const queries = new MockQueries({
appSecret: 'appSecret', tenants: {
resource: 'resource', findTenantSuspendStatusById: mockFindTenantStatusById,
}), },
getOidcConfigs: jest.fn(), });
};
describe('koaTenantGuard middleware', () => { 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 next = jest.fn();
const ctx = createContextWithRouteParameters(); const ctx = createContextWithRouteParameters();
const tenantId = 'tenant_id';
afterEach(() => {
jest.clearAllMocks();
});
it('should return directly if not in cloud', async () => { it('should return directly if not in cloud', async () => {
const stub = Sinon.stub(EnvSet, 'values').value({ const stub = Sinon.stub(EnvSet, 'values').value({
@ -37,48 +32,31 @@ describe('koaTenantGuard middleware', () => {
isCloud: false, isCloud: false,
}); });
await expect(koaTenantGuard(cloudConnection)(ctx, next)).resolves.not.toThrow(); await expect(koaTenantGuard(tenantId, queries)(ctx, next)).resolves.not.toThrow();
expect(clientGetSpy).not.toBeCalled(); expect(mockFindTenantStatusById).not.toBeCalled();
expect(getClientSpy).not.toBeCalled();
stub.restore(); stub.restore();
}); });
it('should reject if tenant is suspended', async () => { it('should reject if tenant is suspended', async () => {
const stub = Sinon.stub(EnvSet, 'values').value({ Sinon.stub(EnvSet, 'values').value({
...EnvSet.values, ...EnvSet.values,
isCloud: true, isCloud: true,
}); });
// @ts-expect-error mock returning value mockFindTenantStatusById.mockResolvedValueOnce({ isSuspended: true });
clientGetSpy.mockResolvedValue({
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) new RequestError('subscription.tenant_suspended', 403)
); );
expect(clientGetSpy).toBeCalledWith('/api/my/tenant');
stub.restore();
}); });
it('should resolve if tenant is not suspended', async () => { it('should resolve if tenant is not suspended', async () => {
const stub = Sinon.stub(EnvSet, 'values').value({ Sinon.stub(EnvSet, 'values').value({
...EnvSet.values, ...EnvSet.values,
isCloud: true, isCloud: true,
}); });
// @ts-expect-error mock returning value mockFindTenantStatusById.mockResolvedValueOnce({ tenantId, isSuspended: false });
clientGetSpy.mockResolvedValue({ await expect(koaTenantGuard(tenantId, queries)(ctx, next)).resolves.not.toThrow();
isSuspended: false,
});
await expect(koaTenantGuard(cloudConnection)(ctx, next)).resolves.not.toThrow();
expect(clientGetSpy).toBeCalledWith('/api/my/tenant');
stub.restore();
}); });
}); });

View file

@ -3,17 +3,11 @@ import { type IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import type Queries from '#src/tenants/Queries.js';
const getAvailableTenant = async (cloudConnection: CloudConnectionLibrary) => {
const client = await cloudConnection.getClient();
const tenant = await client.get('/api/my/tenant');
return tenant;
};
export default function koaTenantGuard<StateT, ContextT extends IRouterParamContext, BodyT>( export default function koaTenantGuard<StateT, ContextT extends IRouterParamContext, BodyT>(
cloudConnection: CloudConnectionLibrary tenantId: string,
{ tenants }: Queries
): Middleware<StateT, ContextT, BodyT> { ): Middleware<StateT, ContextT, BodyT> {
return async (ctx, next) => { return async (ctx, next) => {
const { isCloud } = EnvSet.values; const { isCloud } = EnvSet.values;
@ -22,9 +16,9 @@ export default function koaTenantGuard<StateT, ContextT extends IRouterParamCont
return next(); 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); throw new RequestError('subscription.tenant_suspended', 403);
} }

View 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;

View file

@ -46,7 +46,7 @@ const createRouters = (tenant: TenantContext) => {
const managementRouter: AuthedRouter = new Router(); const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id))); managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id)));
managementRouter.use(koaTenantGuard(tenant.cloudConnection)); managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
applicationRoutes(managementRouter, tenant); applicationRoutes(managementRouter, tenant);
applicationRoleRoutes(managementRouter, tenant); applicationRoleRoutes(managementRouter, tenant);

View file

@ -21,6 +21,7 @@ import { createRolesQueries } from '#src/queries/roles.js';
import { createScopeQueries } from '#src/queries/scope.js'; import { createScopeQueries } from '#src/queries/scope.js';
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js'; import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
import SsoConnectorQueries from '#src/queries/sso-connectors.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 UserSsoIdentityQueries from '#src/queries/user-sso-identities.js';
import { createUserQueries } from '#src/queries/user.js'; import { createUserQueries } from '#src/queries/user.js';
import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createUsersRolesQueries } from '#src/queries/users-roles.js';
@ -51,6 +52,7 @@ export default class Queries {
organizations = new OrganizationQueries(this.pool); organizations = new OrganizationQueries(this.pool);
ssoConnectors = new SsoConnectorQueries(this.pool); ssoConnectors = new SsoConnectorQueries(this.pool);
userSsoIdentities = new UserSsoIdentityQueries(this.pool); userSsoIdentities = new UserSsoIdentityQueries(this.pool);
tenants = createTenantQueries(this.pool);
constructor( constructor(
public readonly pool: CommonQueryMethods, public readonly pool: CommonQueryMethods,

View file

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

View file

@ -13,7 +13,7 @@ revoke all privileges
from logto_tenant_${database}; from logto_tenant_${database};
-- Allow limited select to perform the RLS policy query in `after_each` (using select ... from tenants ...) -- 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 on table tenants
to logto_tenant_${database}; to logto_tenant_${database};