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 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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();
|
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);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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};
|
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};
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue