From 9ed66a8593a5b25a969e773470420035f27ed4d9 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 16 Jan 2023 15:11:40 +0800 Subject: [PATCH] feat(console,core)!: use rbac scope to control management resource (#2942) --- packages/console/package.json | 2 +- packages/console/src/App.tsx | 8 +++- packages/core/src/middleware/koa-auth.test.ts | 24 ++++++----- packages/core/src/middleware/koa-auth.ts | 19 ++++----- packages/core/src/routes/init.ts | 4 +- .../core/src/routes/interaction/consent.ts | 15 +++++-- packages/demo-app/package.json | 2 +- packages/integration-tests/package.json | 2 +- pnpm-lock.yaml | 40 +++++++------------ 9 files changed, 59 insertions(+), 57 deletions(-) diff --git a/packages/console/package.json b/packages/console/package.json index dc16bbe22..779d46737 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -23,7 +23,7 @@ "@logto/language-kit": "workspace:*", "@logto/phrases": "workspace:*", "@logto/phrases-ui": "workspace:*", - "@logto/react": "1.0.0-beta.14", + "@logto/react": "1.0.0-beta.15", "@logto/schemas": "workspace:*", "@mdx-js/react": "^1.6.22", "@parcel/core": "2.8.2", diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index fe1e46c91..412b0487e 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -1,6 +1,10 @@ import { UserScope } from '@logto/core-kit'; import { LogtoProvider } from '@logto/react'; -import { adminConsoleApplicationId, managementResource } from '@logto/schemas'; +import { + adminConsoleApplicationId, + managementResource, + managementResourceScope, +} from '@logto/schemas'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { SWRConfig } from 'swr'; @@ -139,7 +143,7 @@ const App = () => ( endpoint: window.location.origin, appId: adminConsoleApplicationId, resources: [managementResource.indicator], - scopes: [UserScope.Identities, UserScope.CustomData], + scopes: [UserScope.Identities, UserScope.CustomData, managementResourceScope.name], }} >
diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index 723661845..b227b18df 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -1,4 +1,4 @@ -import { UserRole } from '@logto/schemas'; +import { managementResourceScope } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -15,7 +15,9 @@ const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); const { jwtVerify } = mockEsm('jose', () => ({ - jwtVerify: jest.fn().mockReturnValue({ payload: { sub: 'fooUser', role_names: ['admin'] } }), + jwtVerify: jest + .fn() + .mockReturnValue({ payload: { sub: 'fooUser', scope: managementResourceScope.name } }), })); const koaAuth = await pickDefault(import('./koa-auth.js')); @@ -169,7 +171,7 @@ describe('koaAuth middleware', () => { expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); }); - it('expect to throw if jwt role_names is missing', async () => { + it('expect to throw if jwt scope is missing', async () => { jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } })); ctx.request = { @@ -179,14 +181,14 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( - forbiddenError - ); + await expect( + koaAuth(envSetForTest, managementResourceScope.name)(ctx, next) + ).rejects.toMatchError(forbiddenError); }); - it('expect to throw if jwt role_names does not include admin', async () => { + it('expect to throw if jwt scope does not include management resource scope', async () => { jwtVerify.mockImplementationOnce(() => ({ - payload: { sub: 'fooUser', role_names: ['foo'] }, + payload: { sub: 'fooUser', scope: 'foo' }, })); ctx.request = { @@ -196,9 +198,9 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( - forbiddenError - ); + await expect( + koaAuth(envSetForTest, managementResourceScope.name)(ctx, next) + ).rejects.toMatchError(forbiddenError); }); it('expect to throw unauthorized error if unknown error occurs', async () => { diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index a457107b4..bcbcea0a0 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -1,11 +1,11 @@ import type { IncomingHttpHeaders } from 'http'; -import { UserRole, managementResource } from '@logto/schemas'; +import { managementResource, managementResourceScope } from '@logto/schemas'; import type { Optional } from '@silverhand/essentials'; -import { conditional } from '@silverhand/essentials'; import { jwtVerify } from 'jose'; import type { MiddlewareType, Request } from 'koa'; import type { IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; @@ -42,6 +42,7 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) = type TokenInfo = { sub: string; clientId: unknown; + scopes: string[]; roleNames?: string[]; }; @@ -54,13 +55,13 @@ export const verifyBearerTokenFromRequest = async ( const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; if ((!isProduction || isIntegrationTest) && userId) { - return { sub: userId, clientId: undefined, roleNames: [UserRole.Admin] }; + return { sub: userId, clientId: undefined, scopes: [managementResourceScope.name] }; } try { const { localJWKSet, issuer } = envSet.oidc; const { - payload: { sub, client_id: clientId, role_names: roleNames }, + payload: { sub, client_id: clientId, scope = '' }, } = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, { issuer, audience: resourceIndicator, @@ -68,7 +69,7 @@ export const verifyBearerTokenFromRequest = async ( assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); - return { sub, clientId, roleNames: conditional(Array.isArray(roleNames) && roleNames) }; + return { sub, clientId, scopes: z.string().parse(scope).split(' ') }; } catch (error: unknown) { if (error instanceof RequestError) { throw error; @@ -80,18 +81,18 @@ export const verifyBearerTokenFromRequest = async ( export default function koaAuth( envSet: EnvSet, - forRole?: UserRole + forScope?: string ): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { - const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest( + const { sub, clientId, scopes } = await verifyBearerTokenFromRequest( envSet, ctx.request, managementResource.indicator ); - if (forRole) { + if (forScope) { assertThat( - roleNames?.includes(forRole), + scopes.includes(forScope), new RequestError({ code: 'auth.forbidden', status: 403 }) ); } diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 886204f86..704249350 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -1,4 +1,4 @@ -import { UserRole } from '@logto/schemas'; +import { managementResourceScope } from '@logto/schemas'; import Koa from 'koa'; import Router from 'koa-router'; @@ -32,7 +32,7 @@ const createRouters = (tenant: TenantContext) => { interactionRoutes(interactionRouter, tenant); const managementRouter: AuthedRouter = new Router(); - managementRouter.use(koaAuth(tenant.envSet, UserRole.Admin)); + managementRouter.use(koaAuth(tenant.envSet, managementResourceScope.name)); applicationRoutes(managementRouter, tenant); settingRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant); diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index dbc7d10f5..8e77e61aa 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -1,4 +1,8 @@ -import { adminConsoleApplicationId, UserRole } from '@logto/schemas'; +import { + adminConsoleApplicationId, + managementResourceId, + managementResourceScope, +} from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import type Router from 'koa-router'; import { z } from 'zod'; @@ -29,12 +33,15 @@ export default function consentRoutes( const { accountId } = session; - // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console + // Block non-admin user from consenting to admin console if (String(client_id) === adminConsoleApplicationId) { - const { roleNames } = await libraries.users.findUserByIdWithRoles(accountId); + const scopes = await libraries.users.findUserScopesForResourceId( + accountId, + managementResourceId + ); assertThat( - roleNames.includes(UserRole.Admin), + scopes.some(({ name }) => name === managementResourceScope.name), new RequestError({ code: 'auth.forbidden', status: 401 }) ); } diff --git a/packages/demo-app/package.json b/packages/demo-app/package.json index 7af2682b6..f0b1fc85f 100644 --- a/packages/demo-app/package.json +++ b/packages/demo-app/package.json @@ -19,7 +19,7 @@ "@logto/core-kit": "workspace:*", "@logto/language-kit": "workspace:*", "@logto/phrases": "workspace:*", - "@logto/react": "1.0.0-beta.14", + "@logto/react": "1.0.0-beta.15", "@logto/schemas": "workspace:*", "@parcel/core": "2.8.2", "@parcel/transformer-sass": "2.8.2", diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index bd9da473b..ac3c56631 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -23,7 +23,7 @@ "@jest/types": "^29.1.2", "@logto/connector-kit": "workspace:*", "@logto/js": "1.0.0-beta.14", - "@logto/node": "1.0.0-beta.14", + "@logto/node": "1.0.0-beta.15", "@logto/schemas": "workspace:*", "@peculiar/webcrypto": "^1.3.3", "@silverhand/eslint-config": "1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a20a0078c..4c6aa9a2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,7 +109,7 @@ importers: '@logto/language-kit': workspace:* '@logto/phrases': workspace:* '@logto/phrases-ui': workspace:* - '@logto/react': 1.0.0-beta.14 + '@logto/react': 1.0.0-beta.15 '@logto/schemas': workspace:* '@mdx-js/react': ^1.6.22 '@parcel/core': 2.8.2 @@ -178,7 +178,7 @@ importers: '@logto/language-kit': link:../toolkit/language-kit '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui - '@logto/react': 1.0.0-beta.14_react@18.2.0 + '@logto/react': 1.0.0-beta.15_react@18.2.0 '@logto/schemas': link:../schemas '@mdx-js/react': 1.6.22_react@18.2.0 '@parcel/core': 2.8.2 @@ -408,7 +408,7 @@ importers: '@logto/core-kit': workspace:* '@logto/language-kit': workspace:* '@logto/phrases': workspace:* - '@logto/react': 1.0.0-beta.14 + '@logto/react': 1.0.0-beta.15 '@logto/schemas': workspace:* '@parcel/core': 2.8.2 '@parcel/transformer-sass': 2.8.2 @@ -436,7 +436,7 @@ importers: '@logto/core-kit': link:../toolkit/core-kit '@logto/language-kit': link:../toolkit/language-kit '@logto/phrases': link:../phrases - '@logto/react': 1.0.0-beta.14_react@18.2.0 + '@logto/react': 1.0.0-beta.15_react@18.2.0 '@logto/schemas': link:../schemas '@parcel/core': 2.8.2 '@parcel/transformer-sass': 2.8.2_@parcel+core@2.8.2 @@ -466,7 +466,7 @@ importers: '@jest/types': ^29.1.2 '@logto/connector-kit': workspace:* '@logto/js': 1.0.0-beta.14 - '@logto/node': 1.0.0-beta.14 + '@logto/node': 1.0.0-beta.15 '@logto/schemas': workspace:* '@peculiar/webcrypto': ^1.3.3 '@silverhand/eslint-config': 1.3.0 @@ -494,7 +494,7 @@ importers: '@jest/types': 29.1.2 '@logto/connector-kit': link:../toolkit/connector-kit '@logto/js': 1.0.0-beta.14 - '@logto/node': 1.0.0-beta.14 + '@logto/node': 1.0.0-beta.15 '@logto/schemas': link:../schemas '@peculiar/webcrypto': 1.3.3 '@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4 @@ -2310,26 +2310,14 @@ packages: dev: true optional: true - /@logto/browser/1.0.0-beta.14: - resolution: {integrity: sha512-yjD1qtRXbX2E5Jgr5F1BK4SRwNhIlbJZK1yZLZNvOltEG76NhfoqvCI8P5PGIiPwvunB2lqPNJFsNOSI3k0Q+w==} + /@logto/browser/1.0.0-beta.15: + resolution: {integrity: sha512-AbIvIAO9GYXa2G2Komx0+pQ/PrSIdXCpEVZUtjEhQXQ07gmkeq4TRv3Q68H7HyHTVS1S2Rd5eZpWcqTcrj7DhQ==} dependencies: - '@logto/client': 1.0.0-beta.14 + '@logto/client': 1.0.0-beta.15 '@silverhand/essentials': 1.3.0 js-base64: 3.7.3 dev: true - /@logto/client/1.0.0-beta.14: - resolution: {integrity: sha512-quhQJ4rjb1Djhspeq2F5pFxXdgjN5UaWei6nnbUfp12CDhRojKrLJIGl+FDx/HSWuG0b93nwxKnJeJsiX/8E3Q==} - dependencies: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/js': 1.0.0-beta.14 - '@silverhand/essentials': 1.3.0 - camelcase-keys: 7.0.2 - jose: 4.11.1 - lodash.get: 4.4.2 - lodash.once: 4.1.1 - dev: true - /@logto/client/1.0.0-beta.15: resolution: {integrity: sha512-+CrgyUcBcTILpfMPtwIEwBD60XgXUCdu7MpnvNZjd0sNaUpAoyFbUiRKzvbFeF7w9Nc4zO/kgAwbk36kqTXsvw==} dependencies: @@ -2371,8 +2359,8 @@ packages: zod: 3.20.2 dev: true - /@logto/node/1.0.0-beta.14: - resolution: {integrity: sha512-+0S6lBBcG3pOmjEMRQnD+6X0MJ3V3E/4In59ckl/uVr/UgIufvOKWJwWCfsVKyguaO3QweJn19x7YkF8FyO31g==} + /@logto/node/1.0.0-beta.15: + resolution: {integrity: sha512-ELTnVZqKwRH7NIa3C2EWEmWGvocPexpcdpWE9GHFt4N7nYB1mcFoeB5yni6uLdkUNpoW1cKfoGg8ZXEPlaodGQ==} dependencies: '@logto/client': 1.0.0-beta.15 '@silverhand/essentials': 1.3.0 @@ -2382,12 +2370,12 @@ packages: - encoding dev: true - /@logto/react/1.0.0-beta.14_react@18.2.0: - resolution: {integrity: sha512-lHuwpHzJkIbHj/VvhzxmL7hWkyDYA8rInv0sm0M21br43OotgP7fMc62Wj78ty+QIj+or5UGwCcULBa8HySQcQ==} + /@logto/react/1.0.0-beta.15_react@18.2.0: + resolution: {integrity: sha512-G/GZFtPifv9Ln+iL8ka+LhfnawyC2UUFzcEIomO5qP5fN11+eYES80O6a0jNGPm3+8t6otefYPO8skyByohm/w==} peerDependencies: react: '>=16.8.0 || ^18.0.0' dependencies: - '@logto/browser': 1.0.0-beta.14 + '@logto/browser': 1.0.0-beta.15 '@silverhand/essentials': 1.3.0 react: 18.2.0 dev: true