0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(console,core)!: use rbac scope to control management resource (#2942)

This commit is contained in:
wangsijie 2023-01-16 15:11:40 +08:00 committed by GitHub
parent 9292bc086d
commit 9ed66a8593
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 59 additions and 57 deletions

View file

@ -23,7 +23,7 @@
"@logto/language-kit": "workspace:*", "@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*", "@logto/phrases": "workspace:*",
"@logto/phrases-ui": "workspace:*", "@logto/phrases-ui": "workspace:*",
"@logto/react": "1.0.0-beta.14", "@logto/react": "1.0.0-beta.15",
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"@parcel/core": "2.8.2", "@parcel/core": "2.8.2",

View file

@ -1,6 +1,10 @@
import { UserScope } from '@logto/core-kit'; import { UserScope } from '@logto/core-kit';
import { LogtoProvider } from '@logto/react'; 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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { SWRConfig } from 'swr'; import { SWRConfig } from 'swr';
@ -139,7 +143,7 @@ const App = () => (
endpoint: window.location.origin, endpoint: window.location.origin,
appId: adminConsoleApplicationId, appId: adminConsoleApplicationId,
resources: [managementResource.indicator], resources: [managementResource.indicator],
scopes: [UserScope.Identities, UserScope.CustomData], scopes: [UserScope.Identities, UserScope.CustomData, managementResourceScope.name],
}} }}
> >
<Main /> <Main />

View file

@ -1,4 +1,4 @@
import { UserRole } from '@logto/schemas'; import { managementResourceScope } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa'; import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router'; import type { IRouterParamContext } from 'koa-router';
@ -15,7 +15,9 @@ const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest); const { mockEsm } = createMockUtils(jest);
const { jwtVerify } = mockEsm('jose', () => ({ 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')); const koaAuth = await pickDefault(import('./koa-auth.js'));
@ -169,7 +171,7 @@ describe('koaAuth middleware', () => {
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); 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' } })); jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } }));
ctx.request = { ctx.request = {
@ -179,14 +181,14 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( await expect(
forbiddenError 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(() => ({ jwtVerify.mockImplementationOnce(() => ({
payload: { sub: 'fooUser', role_names: ['foo'] }, payload: { sub: 'fooUser', scope: 'foo' },
})); }));
ctx.request = { ctx.request = {
@ -196,9 +198,9 @@ describe('koaAuth middleware', () => {
}, },
}; };
await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( await expect(
forbiddenError koaAuth(envSetForTest, managementResourceScope.name)(ctx, next)
); ).rejects.toMatchError(forbiddenError);
}); });
it('expect to throw unauthorized error if unknown error occurs', async () => { it('expect to throw unauthorized error if unknown error occurs', async () => {

View file

@ -1,11 +1,11 @@
import type { IncomingHttpHeaders } from 'http'; import type { IncomingHttpHeaders } from 'http';
import { UserRole, managementResource } from '@logto/schemas'; import { managementResource, managementResourceScope } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials'; import type { Optional } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
import type { MiddlewareType, Request } from 'koa'; import type { MiddlewareType, Request } from 'koa';
import type { IRouterParamContext } from 'koa-router'; import type { IRouterParamContext } from 'koa-router';
import { z } from 'zod';
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';
@ -42,6 +42,7 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) =
type TokenInfo = { type TokenInfo = {
sub: string; sub: string;
clientId: unknown; clientId: unknown;
scopes: string[];
roleNames?: string[]; roleNames?: string[];
}; };
@ -54,13 +55,13 @@ export const verifyBearerTokenFromRequest = async (
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
if ((!isProduction || isIntegrationTest) && userId) { if ((!isProduction || isIntegrationTest) && userId) {
return { sub: userId, clientId: undefined, roleNames: [UserRole.Admin] }; return { sub: userId, clientId: undefined, scopes: [managementResourceScope.name] };
} }
try { try {
const { localJWKSet, issuer } = envSet.oidc; const { localJWKSet, issuer } = envSet.oidc;
const { const {
payload: { sub, client_id: clientId, role_names: roleNames }, payload: { sub, client_id: clientId, scope = '' },
} = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, { } = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, {
issuer, issuer,
audience: resourceIndicator, audience: resourceIndicator,
@ -68,7 +69,7 @@ export const verifyBearerTokenFromRequest = async (
assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); 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) { } catch (error: unknown) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
throw error; throw error;
@ -80,18 +81,18 @@ export const verifyBearerTokenFromRequest = async (
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>( export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
envSet: EnvSet, envSet: EnvSet,
forRole?: UserRole forScope?: string
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> { ): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => { return async (ctx, next) => {
const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest( const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
envSet, envSet,
ctx.request, ctx.request,
managementResource.indicator managementResource.indicator
); );
if (forRole) { if (forScope) {
assertThat( assertThat(
roleNames?.includes(forRole), scopes.includes(forScope),
new RequestError({ code: 'auth.forbidden', status: 403 }) new RequestError({ code: 'auth.forbidden', status: 403 })
); );
} }

View file

@ -1,4 +1,4 @@
import { UserRole } from '@logto/schemas'; import { managementResourceScope } from '@logto/schemas';
import Koa from 'koa'; import Koa from 'koa';
import Router from 'koa-router'; import Router from 'koa-router';
@ -32,7 +32,7 @@ const createRouters = (tenant: TenantContext) => {
interactionRoutes(interactionRouter, tenant); interactionRoutes(interactionRouter, tenant);
const managementRouter: AuthedRouter = new Router(); const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, UserRole.Admin)); managementRouter.use(koaAuth(tenant.envSet, managementResourceScope.name));
applicationRoutes(managementRouter, tenant); applicationRoutes(managementRouter, tenant);
settingRoutes(managementRouter, tenant); settingRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant);

View file

@ -1,4 +1,8 @@
import { adminConsoleApplicationId, UserRole } from '@logto/schemas'; import {
adminConsoleApplicationId,
managementResourceId,
managementResourceScope,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router'; import type Router from 'koa-router';
import { z } from 'zod'; import { z } from 'zod';
@ -29,12 +33,15 @@ export default function consentRoutes<T>(
const { accountId } = session; 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) { if (String(client_id) === adminConsoleApplicationId) {
const { roleNames } = await libraries.users.findUserByIdWithRoles(accountId); const scopes = await libraries.users.findUserScopesForResourceId(
accountId,
managementResourceId
);
assertThat( assertThat(
roleNames.includes(UserRole.Admin), scopes.some(({ name }) => name === managementResourceScope.name),
new RequestError({ code: 'auth.forbidden', status: 401 }) new RequestError({ code: 'auth.forbidden', status: 401 })
); );
} }

View file

@ -19,7 +19,7 @@
"@logto/core-kit": "workspace:*", "@logto/core-kit": "workspace:*",
"@logto/language-kit": "workspace:*", "@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*", "@logto/phrases": "workspace:*",
"@logto/react": "1.0.0-beta.14", "@logto/react": "1.0.0-beta.15",
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@parcel/core": "2.8.2", "@parcel/core": "2.8.2",
"@parcel/transformer-sass": "2.8.2", "@parcel/transformer-sass": "2.8.2",

View file

@ -23,7 +23,7 @@
"@jest/types": "^29.1.2", "@jest/types": "^29.1.2",
"@logto/connector-kit": "workspace:*", "@logto/connector-kit": "workspace:*",
"@logto/js": "1.0.0-beta.14", "@logto/js": "1.0.0-beta.14",
"@logto/node": "1.0.0-beta.14", "@logto/node": "1.0.0-beta.15",
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@peculiar/webcrypto": "^1.3.3", "@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "1.3.0", "@silverhand/eslint-config": "1.3.0",

40
pnpm-lock.yaml generated
View file

@ -109,7 +109,7 @@ importers:
'@logto/language-kit': workspace:* '@logto/language-kit': workspace:*
'@logto/phrases': workspace:* '@logto/phrases': workspace:*
'@logto/phrases-ui': workspace:* '@logto/phrases-ui': workspace:*
'@logto/react': 1.0.0-beta.14 '@logto/react': 1.0.0-beta.15
'@logto/schemas': workspace:* '@logto/schemas': workspace:*
'@mdx-js/react': ^1.6.22 '@mdx-js/react': ^1.6.22
'@parcel/core': 2.8.2 '@parcel/core': 2.8.2
@ -178,7 +178,7 @@ importers:
'@logto/language-kit': link:../toolkit/language-kit '@logto/language-kit': link:../toolkit/language-kit
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui '@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 '@logto/schemas': link:../schemas
'@mdx-js/react': 1.6.22_react@18.2.0 '@mdx-js/react': 1.6.22_react@18.2.0
'@parcel/core': 2.8.2 '@parcel/core': 2.8.2
@ -408,7 +408,7 @@ importers:
'@logto/core-kit': workspace:* '@logto/core-kit': workspace:*
'@logto/language-kit': workspace:* '@logto/language-kit': workspace:*
'@logto/phrases': workspace:* '@logto/phrases': workspace:*
'@logto/react': 1.0.0-beta.14 '@logto/react': 1.0.0-beta.15
'@logto/schemas': workspace:* '@logto/schemas': workspace:*
'@parcel/core': 2.8.2 '@parcel/core': 2.8.2
'@parcel/transformer-sass': 2.8.2 '@parcel/transformer-sass': 2.8.2
@ -436,7 +436,7 @@ importers:
'@logto/core-kit': link:../toolkit/core-kit '@logto/core-kit': link:../toolkit/core-kit
'@logto/language-kit': link:../toolkit/language-kit '@logto/language-kit': link:../toolkit/language-kit
'@logto/phrases': link:../phrases '@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 '@logto/schemas': link:../schemas
'@parcel/core': 2.8.2 '@parcel/core': 2.8.2
'@parcel/transformer-sass': 2.8.2_@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 '@jest/types': ^29.1.2
'@logto/connector-kit': workspace:* '@logto/connector-kit': workspace:*
'@logto/js': 1.0.0-beta.14 '@logto/js': 1.0.0-beta.14
'@logto/node': 1.0.0-beta.14 '@logto/node': 1.0.0-beta.15
'@logto/schemas': workspace:* '@logto/schemas': workspace:*
'@peculiar/webcrypto': ^1.3.3 '@peculiar/webcrypto': ^1.3.3
'@silverhand/eslint-config': 1.3.0 '@silverhand/eslint-config': 1.3.0
@ -494,7 +494,7 @@ importers:
'@jest/types': 29.1.2 '@jest/types': 29.1.2
'@logto/connector-kit': link:../toolkit/connector-kit '@logto/connector-kit': link:../toolkit/connector-kit
'@logto/js': 1.0.0-beta.14 '@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 '@logto/schemas': link:../schemas
'@peculiar/webcrypto': 1.3.3 '@peculiar/webcrypto': 1.3.3
'@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4 '@silverhand/eslint-config': 1.3.0_k3lfx77tsvurbevhk73p7ygch4
@ -2310,26 +2310,14 @@ packages:
dev: true dev: true
optional: true optional: true
/@logto/browser/1.0.0-beta.14: /@logto/browser/1.0.0-beta.15:
resolution: {integrity: sha512-yjD1qtRXbX2E5Jgr5F1BK4SRwNhIlbJZK1yZLZNvOltEG76NhfoqvCI8P5PGIiPwvunB2lqPNJFsNOSI3k0Q+w==} resolution: {integrity: sha512-AbIvIAO9GYXa2G2Komx0+pQ/PrSIdXCpEVZUtjEhQXQ07gmkeq4TRv3Q68H7HyHTVS1S2Rd5eZpWcqTcrj7DhQ==}
dependencies: dependencies:
'@logto/client': 1.0.0-beta.14 '@logto/client': 1.0.0-beta.15
'@silverhand/essentials': 1.3.0 '@silverhand/essentials': 1.3.0
js-base64: 3.7.3 js-base64: 3.7.3
dev: true 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: /@logto/client/1.0.0-beta.15:
resolution: {integrity: sha512-+CrgyUcBcTILpfMPtwIEwBD60XgXUCdu7MpnvNZjd0sNaUpAoyFbUiRKzvbFeF7w9Nc4zO/kgAwbk36kqTXsvw==} resolution: {integrity: sha512-+CrgyUcBcTILpfMPtwIEwBD60XgXUCdu7MpnvNZjd0sNaUpAoyFbUiRKzvbFeF7w9Nc4zO/kgAwbk36kqTXsvw==}
dependencies: dependencies:
@ -2371,8 +2359,8 @@ packages:
zod: 3.20.2 zod: 3.20.2
dev: true dev: true
/@logto/node/1.0.0-beta.14: /@logto/node/1.0.0-beta.15:
resolution: {integrity: sha512-+0S6lBBcG3pOmjEMRQnD+6X0MJ3V3E/4In59ckl/uVr/UgIufvOKWJwWCfsVKyguaO3QweJn19x7YkF8FyO31g==} resolution: {integrity: sha512-ELTnVZqKwRH7NIa3C2EWEmWGvocPexpcdpWE9GHFt4N7nYB1mcFoeB5yni6uLdkUNpoW1cKfoGg8ZXEPlaodGQ==}
dependencies: dependencies:
'@logto/client': 1.0.0-beta.15 '@logto/client': 1.0.0-beta.15
'@silverhand/essentials': 1.3.0 '@silverhand/essentials': 1.3.0
@ -2382,12 +2370,12 @@ packages:
- encoding - encoding
dev: true dev: true
/@logto/react/1.0.0-beta.14_react@18.2.0: /@logto/react/1.0.0-beta.15_react@18.2.0:
resolution: {integrity: sha512-lHuwpHzJkIbHj/VvhzxmL7hWkyDYA8rInv0sm0M21br43OotgP7fMc62Wj78ty+QIj+or5UGwCcULBa8HySQcQ==} resolution: {integrity: sha512-G/GZFtPifv9Ln+iL8ka+LhfnawyC2UUFzcEIomO5qP5fN11+eYES80O6a0jNGPm3+8t6otefYPO8skyByohm/w==}
peerDependencies: peerDependencies:
react: '>=16.8.0 || ^18.0.0' react: '>=16.8.0 || ^18.0.0'
dependencies: dependencies:
'@logto/browser': 1.0.0-beta.14 '@logto/browser': 1.0.0-beta.15
'@silverhand/essentials': 1.3.0 '@silverhand/essentials': 1.3.0
react: 18.2.0 react: 18.2.0
dev: true dev: true