-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workflow): 다중 운영자 — 레지스트리 + decidedBy 신원 강제 #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # Maestro Workflow 다중 운영자 설계 (운영자 레지스트리 + 신원 기록) | ||
|
|
||
| - 날짜: 2026-08-04 | ||
| - 상태: 확정 (마지막 예약 스펙 — actor 레지스트리 패턴의 운영자판) | ||
| - 범위: `workflow/` 하위만 | ||
|
|
||
| ## 0. 목표 | ||
|
|
||
| 단일 서버 토큰 = 운영자 전권이던 모델을 분리한다: **root(서버 토큰)**는 | ||
| 관리 전용으로 물러나고, 결정은 **개별 운영자 토큰**으로 수행하며 신원이 | ||
| `decidedBy`에 자동 기록된다. | ||
|
|
||
| ## 1. 권한 모델 (엄격 모드) | ||
|
|
||
| | 토큰 | 신원 | 허용 | | ||
| | --- | --- | --- | | ||
| | 서버 토큰 | `root` | 전부 (관리: actor/운영자 등록·폐기·목록 + 결정·조회) | | ||
| | 운영자 토큰 | `operatorId` | 결정·조회 (pending 목록, decide, history, chain) + WS 전체 스트림 | | ||
| | actor 토큰 | `actorId` | 현행 유지 (요청 생성, 자기 결정 폴링/ack/WS) | | ||
|
|
||
| open 모드(토큰 미설정)는 현행 무인증 동작 유지 — `decidedBy`는 body 값 | ||
| (없으면 'operator')을 그대로 쓴다. | ||
|
|
||
| ## 2. 구성요소 | ||
|
|
||
| - `server/operators.js`: actors.js 미러(heartbeat 없음) — 등록(upsert=토큰 | ||
| 회전, sha256 해시 저장), findOperatorByToken, revoke(tokenHash null), | ||
| 목록. 스토어 `MAESTRO_WORKFLOW_OPERATOR_STORE_PATH` | ||
| (기본 `.maestro-workflow-operators.json`). | ||
| - `auth.js` `resolveOperatorAuth(req)`: open → {mode:'open'}, 서버 토큰 → | ||
| {mode:'root', operatorId:'root'}, 운영자 토큰 → {mode:'operator', operatorId}, | ||
| 그 외 401. 관리 라우트는 기존 `isServerAuthorized`(root 전용) 유지. | ||
| - 라우트: `POST /api/operators/register`·`GET /api/operators`· | ||
| `POST /api/operators/:id/revoke` (root 전용, 이력 기록). 운영자급 라우트 | ||
| (GET decision-requests, decide, history, chain)는 resolveOperatorAuth로 전환. | ||
| - **decidedBy**: 엄격 모드에선 토큰 신원으로 강제(body 위조 무시), | ||
| open 모드는 현행 body 사용. | ||
| - WS: WORKFLOW_AUTH 판별 순서 서버 토큰 → **운영자 토큰**(operator 스코프, | ||
| 전체 스트림, AUTH_OK에 operatorId) → actor 토큰. 운영자 revoke 시 해당 | ||
| 소켓 4401(OPERATOR_REVOKED). | ||
| - 대시보드: 무변경 — 토큰 게이트에 운영자 토큰을 넣으면 그대로 동작 | ||
| (AUTH_OK 추가 필드는 무시됨). | ||
|
|
||
| ## 3. 테스트 (`tests/operators.test.mjs`) | ||
|
|
||
| 등록/결정/decidedBy 기록, 위조 decidedBy 무시, 관리 라우트 root 전용, | ||
| revoke 후 HTTP 401+WS 4401(루트 소켓 유지), 운영자 WS 전체 스트림 수신, | ||
| 재시작 후 토큰 유효(영속화), open 모드 무회귀(기존 스위트). | ||
|
|
||
| ## 4. 비범위 | ||
|
|
||
| RBAC/승인선, 운영자별 채널 필터, 대시보드 신원 표시 UI. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| // 운영자 레지스트리 (스펙 2026-08-04 다중 운영자 §2) — actors.js 미러, heartbeat 없음. | ||
| // 토큰은 발급 시 1회만 평문 반환, 레코드에는 sha256 해시만 저장. | ||
| import crypto from 'node:crypto'; | ||
| import { loadStore, saveStore } from './persist.js'; | ||
| import { sanitizeText } from './actors.js'; | ||
|
|
||
| const operatorsById = new Map(); | ||
| let storePath = null; | ||
|
|
||
| function generateOperatorToken() { | ||
| return crypto.randomBytes(24).toString('hex'); | ||
| } | ||
|
|
||
| function hashOperatorToken(token) { | ||
| return crypto.createHash('sha256').update(token).digest('hex'); | ||
| } | ||
|
|
||
| function persist() { | ||
| if (storePath) saveStore(storePath, { items: Array.from(operatorsById.values()) }); | ||
| } | ||
|
|
||
| export function initOperatorStore(path) { | ||
| storePath = path; | ||
| operatorsById.clear(); | ||
| const data = loadStore(path); | ||
| for (const item of data?.items || []) { | ||
| if (item && typeof item.operatorId === 'string' && item.operatorId) { | ||
| operatorsById.set(item.operatorId, item); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 재등록(upsert) = 무조건 토큰 회전. | ||
| export function registerOperator({ operatorId, displayName = '' } = {}) { | ||
| const id = sanitizeText(operatorId, 80); | ||
| if (!id) return null; | ||
|
Comment on lines
+35
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. |
||
| const token = generateOperatorToken(); | ||
| const now = new Date().toISOString(); | ||
| const existing = operatorsById.get(id) || null; | ||
| const operator = { | ||
| operatorId: id, | ||
| displayName: sanitizeText(displayName, 120), | ||
| tokenHash: hashOperatorToken(token), | ||
| createdAt: existing?.createdAt || now, | ||
| updatedAt: now, | ||
| }; | ||
| operatorsById.set(id, operator); | ||
| persist(); | ||
| return { operator, operatorToken: token }; | ||
| } | ||
|
|
||
| export function findOperatorByToken(token) { | ||
| if (!token) return null; | ||
| const tokenHash = hashOperatorToken(token); | ||
| return ( | ||
| Array.from(operatorsById.values()).find((operator) => operator.tokenHash && operator.tokenHash === tokenHash) | ||
| || null | ||
| ); | ||
| } | ||
|
|
||
| export function revokeOperator(operatorId) { | ||
| const operator = operatorsById.get(operatorId); | ||
| if (!operator) return null; | ||
| operator.tokenHash = null; | ||
| operator.updatedAt = new Date().toISOString(); | ||
| persist(); | ||
| return operator; | ||
| } | ||
|
|
||
| export function toPublicOperator(operator) { | ||
| if (!operator) return operator; | ||
| const { tokenHash, ...publicOperator } = operator; | ||
| return publicOperator; | ||
| } | ||
|
|
||
| export function listOperators() { | ||
| return Array.from(operatorsById.values()).map(toPublicOperator); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this register endpoint is used as the documented upsert/token-rotation path for an existing operator, the old token is invalidated for future HTTP auth but any WebSocket that already authenticated with that old token stays
isAuthorizedand continues receiving the full operator stream because only the revoke route callscloseOperatorSockets. In strict mode this means rotating a leaked operator token does not actually remove live access until the stale socket disconnects, so the existing operator's sockets should be closed as part of rotation.Useful? React with 👍 / 👎.