Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/maestro-workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Maestro Harmony 제품군의 범용 승인·결정·이력 앱. 구현은 [`work
- 레인(결정 채널) 대시보드: 승인/반려 시트, 프리셋(spend/publish) 표시, 이력 뷰
- 엄격 모드 대시보드: 토큰 게이트 + WS 첫 메시지 인증 + 자동 재연결 (2026-08-03 스펙)
- actor 토큰 WS 구독: 자기 결정만 스코프 수신, revoke 시 소켓 종료 (2026-08-04 스펙)
- 범위 밖: Policy/자동승인, Delegation, 에이전트 decider, executor 실행, 다중 운영자
- 다중 운영자: 운영자 레지스트리 + decidedBy 신원 강제 (2026-08-04 스펙)
- 범위 밖: Policy/자동승인, Delegation, 에이전트 decider, executor 실행, RBAC/승인선

## 후속 구상

Expand Down
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.
1 change: 1 addition & 0 deletions workflow/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ MAESTRO_WORKFLOW_ALLOWED_ORIGINS=http://localhost:5273,http://127.0.0.1:5273
# MAESTRO_WORKFLOW_ACTOR_STORE_PATH=.maestro-workflow-actors.json
# MAESTRO_WORKFLOW_DECISION_STORE_PATH=.maestro-workflow-decisions.json
# MAESTRO_WORKFLOW_HISTORY_STORE_PATH=.maestro-workflow-history.json
# MAESTRO_WORKFLOW_OPERATOR_STORE_PATH=.maestro-workflow-operators.json
6 changes: 5 additions & 1 deletion workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ actor도 자신의 actorToken으로 같은 `WORKFLOW_AUTH` 핸드셰이크를

- 토큰은 localStorage에 평문 저장된다 — 로컬 신뢰 기기 전제. TLS 없음, 기본
`HOST=127.0.0.1` 로컬 전용 전제를 유지하라.
- 다중 운영자/권한 분리는 후속 스펙으로 예약한다.
- 다중 운영자 구현됨(2026-08-04): root(서버 토큰)는 관리 전용(actor/운영자
등록·폐기), 결정·조회는 개별 운영자 토큰(`POST /api/operators/register`)으로
수행하며 엄격 모드에선 `decidedBy`가 토큰 신원으로 강제된다(위조 불가).
대시보드 토큰 게이트에 운영자 토큰을 넣으면 그대로 동작한다. RBAC/승인선은
후속.
- 채널 에이전트 연동의 Workflow측 토대는 구현됨(2026-08-04): 프리셋
`email-triage`/`email-reply` 표시, `parentRequestId` 요청 체인 +
`GET /api/decision-requests/:id/chain`(운영자 토큰). 커넥터(IMAP/발송)
Expand Down
99 changes: 93 additions & 6 deletions workflow/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
// 실행: node server.js (기본 http://127.0.0.1:8090)
import http from 'node:http';
import { WebSocketServer, WebSocket as WSWebSocket } from 'ws';
import { PORT, HOST, ALLOWED_ORIGINS, ACTOR_STORE_PATH, DECISION_STORE_PATH, HISTORY_STORE_PATH, SERVER_TOKEN, WS_AUTH_TIMEOUT_MS } from './server/config.js';
import { PORT, HOST, ALLOWED_ORIGINS, ACTOR_STORE_PATH, DECISION_STORE_PATH, HISTORY_STORE_PATH, OPERATOR_STORE_PATH, SERVER_TOKEN, WS_AUTH_TIMEOUT_MS } from './server/config.js';
import {
initOperatorStore,
listOperators,
registerOperator,
revokeOperator,
toPublicOperator,
} from './server/operators.js';
import {
findActorByToken,
heartbeatActor,
Expand All @@ -13,7 +20,8 @@ import {
revokeActor,
toPublicActor,
} from './server/actors.js';
import { authorizeActor, isServerAuthorized } from './server/auth.js';
import { authorizeActor, isServerAuthorized, resolveOperatorAuth } from './server/auth.js';
import { findOperatorByToken } from './server/operators.js';
import {
acknowledgeDecision,
countPendingRequests,
Expand All @@ -31,6 +39,7 @@ import { appendHistory, initHistoryStore, listHistory } from './server/history.j
initActorStore(ACTOR_STORE_PATH);
initDecisionStore(DECISION_STORE_PATH);
initHistoryStore(HISTORY_STORE_PATH);
initOperatorStore(OPERATOR_STORE_PATH);

export function sendJson(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
Expand Down Expand Up @@ -164,6 +173,60 @@ async function handleRequest(req, res) {
return;
}

// ── 운영자 레지스트리 (root 전용) ────────────────────────────────────────
if (req.method === 'POST' && pathname === '/api/operators/register') {
if (!isServerAuthorized(req)) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
let data;
try {
data = await readJsonBody(req);
} catch (error) {
sendJson(res, error?.code === 'BODY_TOO_LARGE' ? 413 : 400, { error: error?.code || 'Invalid JSON body' });
return;
}
const registered = registerOperator(data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close old operator sockets when rotating tokens

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 isAuthorized and continues receiving the full operator stream because only the revoke route calls closeOperatorSockets. 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 👍 / 👎.

if (!registered) {
sendJson(res, 400, { error: 'OPERATOR_ID_REQUIRED' });
return;
}
recordHistory({ event: 'OPERATOR_REGISTERED', operatorId: registered.operator.operatorId });
sendJson(res, 200, {
success: true,
item: toPublicOperator(registered.operator),
operatorToken: registered.operatorToken,
});
return;
}

if (req.method === 'GET' && pathname === '/api/operators') {
if (!isServerAuthorized(req)) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
sendJson(res, 200, { items: listOperators() });
return;
}

const operatorRevokeMatch = pathname.match(/^\/api\/operators\/([^/]+)\/revoke$/);
if (req.method === 'POST' && operatorRevokeMatch) {
if (!isServerAuthorized(req)) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
const operatorId = decodeURIComponent(operatorRevokeMatch[1]);
const operator = revokeOperator(operatorId);
if (!operator) {
sendJson(res, 404, { error: 'OPERATOR_NOT_FOUND' });
return;
}
recordHistory({ event: 'OPERATOR_REVOKED', operatorId });
closeOperatorSockets(operatorId);
sendJson(res, 200, { success: true, item: toPublicOperator(operator) });
return;
}

// ── DecisionRequest (actor 토큰) ─────────────────────────────────────────
if (req.method === 'POST' && pathname === '/api/decision-requests') {
const auth = authorizeActor(req, res);
Expand Down Expand Up @@ -209,7 +272,7 @@ async function handleRequest(req, res) {

const chainMatch = pathname.match(/^\/api\/decision-requests\/([^/]+)\/chain$/);
if (req.method === 'GET' && chainMatch) {
if (!isServerAuthorized(req)) {
if (!resolveOperatorAuth(req).ok) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
Expand All @@ -223,7 +286,7 @@ async function handleRequest(req, res) {
}

if (req.method === 'GET' && pathname === '/api/decision-requests') {
if (!isServerAuthorized(req)) {
if (!resolveOperatorAuth(req).ok) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
Expand All @@ -234,7 +297,8 @@ async function handleRequest(req, res) {

const decideMatch = pathname.match(/^\/api\/decision-requests\/([^/]+)\/decide$/);
if (req.method === 'POST' && decideMatch) {
if (!isServerAuthorized(req)) {
const operatorAuth = resolveOperatorAuth(req);
if (!operatorAuth.ok) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
Expand All @@ -250,6 +314,10 @@ async function handleRequest(req, res) {
return;
}
const requestId = decodeURIComponent(decideMatch[1]);
// 엄격 모드에선 신원을 토큰에서 강제한다 — body decidedBy 위조 방지 (스펙 §2)
if (operatorAuth.mode !== 'open') {
data.decidedBy = operatorAuth.operatorId;
}
const result = decideRequest(requestId, data);
if (result.error) {
sendJson(res, result.status, { error: result.error });
Expand Down Expand Up @@ -313,7 +381,7 @@ async function handleRequest(req, res) {
}

if (req.method === 'GET' && pathname === '/api/history') {
if (!isServerAuthorized(req)) {
if (!resolveOperatorAuth(req).ok) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
Expand Down Expand Up @@ -371,6 +439,17 @@ wss.on('connection', (socket) => {
};
if (SERVER_TOKEN && token === SERVER_TOKEN) {
grant('operator');
socket.operatorId = 'root';
return;
}
// 개별 운영자 토큰 → 전체 스트림 (스펙 2026-08-04 다중 운영자 §2)
const operator = token ? findOperatorByToken(token) : null;
if (operator) {
socket.isAuthorized = true;
socket.scope = 'operator';
socket.operatorId = operator.operatorId;
if (authTimer) clearTimeout(authTimer);
socket.send(JSON.stringify({ type: 'WORKFLOW_AUTH_OK', scope: 'operator', operatorId: operator.operatorId }));
return;
}
const actor = token ? findActorByToken(token) : null;
Expand Down Expand Up @@ -398,6 +477,14 @@ function broadcast(data, { targetActorId = null } = {}) {
});
}

function closeOperatorSockets(operatorId) {
wss.clients.forEach((client) => {
if (client.scope === 'operator' && client.operatorId === operatorId) {
client.close(4401, 'OPERATOR_REVOKED');
}
});
}

function closeActorSockets(actorId) {
wss.clients.forEach((client) => {
if (client.scope === 'actor' && client.actorId === actorId) {
Expand Down
12 changes: 12 additions & 0 deletions workflow/server/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 본체와 달리 grace 경로 없음 — 엄격 per-actor 전용.
import { SERVER_TOKEN } from './config.js';
import { findActorByToken } from './actors.js';
import { findOperatorByToken } from './operators.js';

export function extractBearerToken(headerValue) {
if (typeof headerValue !== 'string') return null;
Expand All @@ -16,6 +17,17 @@ export function isServerAuthorized(req) {
return extractBearerToken(req.headers.authorization) === SERVER_TOKEN;
}

// 운영자급 인가 (스펙 2026-08-04 다중 운영자 §1): root(서버 토큰) 또는 개별 운영자 토큰.
export function resolveOperatorAuth(req) {
if (!SERVER_TOKEN) return { ok: true, mode: 'open', operatorId: null };
const token = extractBearerToken(req.headers.authorization);
if (!token) return { ok: false, status: 401, error: 'Unauthorized' };
if (token === SERVER_TOKEN) return { ok: true, mode: 'root', operatorId: 'root' };
const operator = findOperatorByToken(token);
if (operator) return { ok: true, mode: 'operator', operatorId: operator.operatorId };
return { ok: false, status: 401, error: 'Unauthorized' };
}

export function resolveActorAuth(req) {
if (!SERVER_TOKEN) return { ok: true, mode: 'open', actorId: null };
const token = extractBearerToken(req.headers.authorization);
Expand Down
4 changes: 4 additions & 0 deletions workflow/server/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const HISTORY_STORE_PATH = path.resolve(
ROOT_DIR,
process.env.MAESTRO_WORKFLOW_HISTORY_STORE_PATH || '.maestro-workflow-history.json',
);
export const OPERATOR_STORE_PATH = path.resolve(
ROOT_DIR,
process.env.MAESTRO_WORKFLOW_OPERATOR_STORE_PATH || '.maestro-workflow-operators.json',
);

export const ALLOWED_ORIGINS = (
process.env.MAESTRO_WORKFLOW_ALLOWED_ORIGINS
Expand Down
78 changes: 78 additions & 0 deletions workflow/server/operators.js
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject the reserved root operator id

Because root is the server-token identity, allowing an operator to register with operatorId: 'root' makes that operator's decisions audit as decidedBy: 'root' and also collides with server-token WebSocket bookkeeping when /api/operators/root/revoke closes operator sockets by id. In strict mode this breaks the identity boundary and can close root dashboard sockets, so the registry should reserve/reject this id.

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);
}
1 change: 1 addition & 0 deletions workflow/tests/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function startServer({ serverToken = '', tempDir = null, extraEnv =
MAESTRO_WORKFLOW_ACTOR_STORE_PATH: resolve(dataDir, 'actors.json'),
MAESTRO_WORKFLOW_DECISION_STORE_PATH: resolve(dataDir, 'decisions.json'),
MAESTRO_WORKFLOW_HISTORY_STORE_PATH: resolve(dataDir, 'history.json'),
MAESTRO_WORKFLOW_OPERATOR_STORE_PATH: resolve(dataDir, 'operators.json'),
...extraEnv,
},
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
3 changes: 2 additions & 1 deletion workflow/tests/history.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ test('full flow leaves append-only audit trail that survives restart', async ()
})).json()).items.find((item) => item.event === 'DECIDED');
assert.equal(decidedEntry.decision, 'approve');
assert.equal(decidedEntry.subjectType, 'spend');
assert.equal(decidedEntry.decidedBy, 'operator');
// 다중 운영자 스펙(2026-08-04): 엄격 모드에선 토큰 신원이 강제된다 — 서버 토큰 = root
assert.equal(decidedEntry.decidedBy, 'root');

await first.stop();

Expand Down
Loading
Loading