diff --git a/packages/core/src/middleware/koa-tenant-guard.test.ts b/packages/core/src/middleware/koa-tenant-guard.test.ts index d6c18baf9..0d7c70f52 100644 --- a/packages/core/src/middleware/koa-tenant-guard.test.ts +++ b/packages/core/src/middleware/koa-tenant-guard.test.ts @@ -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({ 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(); }); }); diff --git a/packages/core/src/middleware/koa-tenant-guard.ts b/packages/core/src/middleware/koa-tenant-guard.ts index a5ca8d697..825960f47 100644 --- a/packages/core/src/middleware/koa-tenant-guard.ts +++ b/packages/core/src/middleware/koa-tenant-guard.ts @@ -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( - cloudConnection: CloudConnectionLibrary + tenantId: string, + { tenants }: Queries ): Middleware { return async (ctx, next) => { const { isCloud } = EnvSet.values; @@ -22,9 +16,9 @@ export default function koaTenantGuard { + const { table, fields } = convertToIdentifiers({ + table: Tenants.tableName, + fields: Tenants.rawKeys, + }); + + const findTenantSuspendStatusById = async ( + id: string + ): Promise> => { + const result = await pool.one>(sql` + select ${sql.join([fields.id, fields.isSuspended], sql`, `)} + from ${table} + where ${fields.id} = ${id} + `); + + return result; + }; + + return { + findTenantSuspendStatusById, + }; +}; + +export default createTenantQueries; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 99f134c22..f532127e8 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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); diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 3ea1f81bb..a55282356 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -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, diff --git a/packages/schemas/alterations/next-1707360939-grant-is-suspended-read-permission.ts b/packages/schemas/alterations/next-1707360939-grant-is-suspended-read-permission.ts new file mode 100644 index 000000000..8a3918fae --- /dev/null +++ b/packages/schemas/alterations/next-1707360939-grant-is-suspended-read-permission.ts @@ -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_ 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; diff --git a/packages/schemas/tables/_after_all.sql b/packages/schemas/tables/_after_all.sql index 8a3d9b0e4..7ab4cfee7 100644 --- a/packages/schemas/tables/_after_all.sql +++ b/packages/schemas/tables/_after_all.sql @@ -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};