Permission 프레임워크와 통합하기
Backstage permissions 프레임워크는 플러그인 안에서 누가 무엇을 할 수 있는지 구조적으로 제어할 수 있는 방법을 제공해요. 라우트 핸들러 곳곳에 인증 로직을 흩어놓는 대신, 권한을 선언적으로 정의하고 중앙 정책이 각 동작을 허용할지 거부할지 결정하게 해요.
출처: 문서
본문
Permissions
Permissions 프레임워크란 무엇인가요?
Backstage permissions 프레임워크는 플러그인 안에서 누가 무엇을 할 수 있는지 구조적으로 제어할 수 있는 방법을 제공해요. 라우트 핸들러 곳곳에 인증 로직을 흩어놓는 대신, 권한을 선언적으로 정의하고 중앙 정책이 각 동작을 허용할지 거부할지 결정하게 해요.
권한에는 두 종류가 있어요.
기본 권한(Basic permissions)은 특정 리소스와 관계없는 동작에 적용돼요. todo를 만드는 게 좋은 예인데, 어떤 todo를 만들든 그 동작이 허용되거나 허용되지 않을 뿐이에요. 정책은 확정적인 ALLOW 또는 DENY를 반환해요.
리소스 권한(Resource permissions)은 특정 리소스에 대한 동작에 적용돼요. 특정 todo를 읽는 게 좋은 예인데, 허용 여부가 그 todo를 누가 만들었는지에 따라 달라질 수 있어요. 기본 ALLOW/DENY에 더해 정책은 CONDITIONAL 결정을 반환할 수도 있어요. CONDITIONAL 결정은 특정 리소스에 대해 평가되어야 하며, 리소스별 ALLOW 또는 DENY를 만들어내요.
프레임워크는 라우트 핸들러와 비즈니스 로직 사이에 위치해요. 핸들러가 "이건 허용되나요?"라고 묻고, 프레임워크는 활성 정책을 참고하며, 핸들러는 진행하거나 NotAllowedError를 던져요.
일반적인 통합 지점
대부분의 플러그인은 두 수준에서 통합해요.
백엔드 플러그인은 권한을 정의하고 프레임워크에 등록하며 라우트 핸들러 안에서 이를 시행하는 곳이에요.
공통 패키지(예: @internal/plugin-todo-common)는 권한 정의를 내보내 어디서든 참조할 수 있게 하는 곳이에요. 백엔드, 프론트엔드, 그리고 도입자가 작성하는 어떤 정책에서도 참조할 수 있어요.
이렇게 나누는 게 중요한 이유는 정책 작성자가 자기 정책을 쓸 때 권한 객체를 참조할 수 있어야 하기 때문이에요. 그 정의가 백엔드 패키지 안에 있다면, 백엔드 코드에 어울리지 않는 곳에 백엔드 코드에 대한 의존성을 강제하게 돼요.
비공개 TODO 만들기
여기서의 목표는 사용자가 자기 자신의 todo만 읽을 수 있게 하는 거예요. 결정이 리소스 자체의 속성에 달려 있으므로 이것은 리소스 권한이에요.
권한 정의하기
공통 패키지에서 todo를 읽는 리소스 권한을 정의하세요.
// plugins/todo-common/src/permissions.tsimport { createPermission } from '@backstage/plugin-permission-common';export const TODO_RESOURCE_TYPE = 'todo-item';export const todoReadPermission = createPermission({ name: 'todo.read', attributes: { action: 'read' }, resourceType: TODO_RESOURCE_TYPE,});export const todoPermissions = [todoReadPermission];
resourceType 필드는 이 권한을 특정 종류의 리소스와 연결해요. 이 문자열을 명명된 상수(TODO_RESOURCE_TYPE)로 내보내면 백엔드 규칙에서 문자열을 반복하지 않고 상수를 가져와 쓸 수 있어서, 미묘한 불일치를 막을 수 있어요.
권한 규칙 정의하기
규칙은 프레임워크가 리소스에 대해 평가하는 조건이에요. 각 규칙은 두 부분으로 나뉘어요. apply는 인메모리 리소스를 검사하고, toQuery는 그 조건을 데이터베이스가 쓸 수 있는 필터로 변환해요.
// plugins/todo-backend/src/service/rules.tsimport { createPermissionResourceRef, createPermissionRule,} from '@backstage/plugin-permission-node';import { TODO_RESOURCE_TYPE } from '@internal/plugin-todo-common';import * as z from 'zod';import type { TodoItem } from './services/TodoListService';export const todoResourceRef = createPermissionResourceRef< TodoItem, { createdBy: string }>().with({ pluginId: 'todo', resourceType: TODO_RESOURCE_TYPE,});export const isCreator = createPermissionRule({ name: 'IS_CREATOR', description: 'Allow if the todo was created by the current user', resourceRef: todoResourceRef, paramsSchema: z.object({ userRef: z.string().describe('The entity ref of the user'), }), apply(todo, { userRef }) { return todo.createdBy === userRef; }, toQuery({ userRef }) { return { property: 'createdBy', values: [userRef] }; },});export const rules = { isCreator };
apply와 toQuery 함수는 항상 논리적으로 동일한 결과를 내야 해요. 둘이 어긋나면 프레임워크가 데이터베이스를 검사할 때와 로드된 리소스를 검사할 때 사용자가 일관되지 않은 결과를 보게 돼요.
리소스 타입 등록하기
플러그인 설정에서 규칙과 함께 리소스 타입을 등록하세요.
// plugins/todo-backend/src/plugin.tsimport { coreServices, createBackendPlugin,} from '@backstage/backend-plugin-api';import { todoReadPermission } from '@internal/plugin-todo-common';import { todoResourceRef, rules } from './service/rules';import { todoListServiceRef } from './services/TodoListService';export const todoPlugin = createBackendPlugin({ pluginId: 'todo', register(env) { env.registerInit({ deps: { httpRouter: coreServices.httpRouter, httpAuth: coreServices.httpAuth, permissions: coreServices.permissions, permissionsRegistry: coreServices.permissionsRegistry, todoList: todoListServiceRef, }, async init({ httpRouter, httpAuth, permissions, permissionsRegistry, todoList, }) { permissionsRegistry.addResourceType({ resourceRef: todoResourceRef, permissions: [todoReadPermission], rules: Object.values(rules), getResources: async resourceRefs => { return Promise.all( resourceRefs.map(ref => todoList.getTodo({ id: ref }).catch(() => undefined), ), ); }, }); const router = await createRouter({ httpAuth, permissions, todoList }); httpRouter.use(router); }, }); },});
getResources는 프레임워크가 조건부 결정을 평가하기 위해 리소스를 로드해야 할 때 호출해요. 존재하지 않는 ref에 대해서는 undefined를 반환하세요.
라우트 핸들러에서 권한 시행하기
라우트 핸들러에서는 리소스 권한에 authorizeConditional을 사용하세요. authorize와 달리, 이 함수는 하드 스톱이 아니라 필터로 적용하는 조건부 결정을 반환할 수 있어요.
// plugins/todo-backend/src/service/router.tsimport { HttpAuthService, PermissionsService,} from '@backstage/backend-plugin-api';import { NotAllowedError } from '@backstage/errors';import { AuthorizeResult } from '@backstage/plugin-permission-common';import { todoReadPermission } from '@internal/plugin-todo-common';import { todoListServiceRef } from './services/TodoListService';export async function createRouter({ httpAuth, permissions, todoList,}: { httpAuth: HttpAuthService; permissions: PermissionsService; todoList: typeof todoListServiceRef.T;}): Promise<express.Router> { const router = Router(); router.use(express.json()); router.get('/todos', async (req, res) => { const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const decision = ( await permissions.authorizeConditional( [{ permission: todoReadPermission }], { credentials }, ) )[0]; if (decision.result === AuthorizeResult.DENY) { throw new NotAllowedError(); } // If CONDITIONAL, pass the conditions to your data layer as a filter. // If ALLOW, pass no filter (return everything). const result = await todoList.listTodos( decision.result === AuthorizeResult.CONDITIONAL ? decision.conditions : undefined, ); res.json(result); }); // ... other routes return router;}
조건부 경로 덕분에 사용자는 핸들러가 정책이 실제로 무엇인지 알 필요 없이, 정책이 허용하는 데이터만 보게 돼요. 정책은 도입자가 관리할 몫이에요.
정책 작성자를 위한 조건 헬퍼 내보내기
자체 권한 정책을 작성하는 도입자는 여러분의 규칙을 사용해 조건을 표현할 수 있어야 해요. 백엔드 패키지에서 헬퍼를 내보내세요.
// plugins/todo-backend/src/conditionExports.tsimport { createConditionExports } from '@backstage/plugin-permission-node';import { todoResourceRef, rules } from './service/rules';const { conditions, createConditionalDecision } = createConditionExports({ resourceRef: todoResourceRef, rules,});export const todoConditions = conditions;export const createTodoConditionalDecision = createConditionalDecision;
이것들을 패키지의 src/index.ts에서 다시 내보내세요. 그러면 도입자는 이렇게 정책을 작성할 수 있어요.
import { todoConditions, createTodoConditionalDecision,} from '@internal/plugin-todo-backend';import { todoReadPermission } from '@internal/plugin-todo-common';class MyPolicy implements PermissionPolicy { async handle(request: PolicyQuery, user?: PolicyQueryUser) { if (isPermission(request.permission, todoReadPermission)) { return createTodoConditionalDecision( request.permission, todoConditions.isCreator({ userRef: user?.info.userEntityRef ?? '' }), ); } return { result: AuthorizeResult.ALLOW }; }}
이렇게 하면 도입자가 데이터 레이어의 내부를 이해하지 않고도 플러그인의 접근 제어를 커스터마이즈할 수 있는 타입 안전하고 발견 가능한 API를 얻게 돼요.
TODO를 만들 수 있는 사람 제한하기
todo를 만들 수 있는 사람을 제한하는 건 더 간단해요. 아직 관련된 리소스가 없으므로 이것은 기본 권한이에요. 정책은 확정적인 ALLOW 또는 DENY를 반환해요.
create 권한 정의하기
공통 패키지에 create 권한을 추가하세요.
// plugins/todo-common/src/permissions.tsexport const todoCreatePermission = createPermission({ name: 'todo.create', attributes: { action: 'create' },});export const todoPermissions = [todoReadPermission, todoCreatePermission];
프레임워크에 권한 등록하기
플러그인 설정에서 addResourceType이 아니라 addPermissions로 기본 권한을 등록하세요.
permissionsRegistry.addPermissions([todoCreatePermission]);
create 핸들러에서 권한 시행하기
기본 권한에는 authorizeConditional 대신 authorize를 사용하세요. 결과는 항상 확정적이에요.
router.post('/todos', async (req, res) => { const parsed = todoSchema.safeParse(req.body); if (!parsed.success) { throw new InputError(parsed.error.toString()); } const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const decision = ( await permissions.authorize([{ permission: todoCreatePermission }], { credentials, }) )[0]; if (decision.result !== AuthorizeResult.ALLOW) { throw new NotAllowedError('You are not permitted to create todos'); } const result = await todoList.createTodo(parsed.data, { credentials }); res.status(201).json(result);});
도입자의 정책은 이제 이 권한을 원하는 대로 제어할 수 있어요. 특정 그룹으로 제한하거나, 사용자 엔티티에 특정 애너테이션을 요구하거나, 모두에게 열어둘 수도 있어요. 플러그인은 그걸 알 필요가 없어요.