룸 Board에서 세분화된 작업 클레임 권한 적용 (accept_work vs complete_work)
README 링크에서 발견 · today
Board claim/complete 게이트를 권한별 검사로 분리하고, 실패 우선 방식으로 전환합니다.
What they did
작성자는 멀티 에이전트 'room' 서버에서 보안 발견 사항(UT wave-2 lane 05-03, F1, 심각도 S2, 실행됨)을 수정했습니다. 결함은 다음과 같았습니다: 오직 review 프로필([verify])만 보유한 멤버가 작업을 클레임하고 스스로 done으로 종료할 수 있었는데, 이는 Board의 claim 및 complete 엔드포인트가 초대 미리보기에서 공개하는 개별 accept_work / complete_work 권한이 아니라 ANY work 프로필 보유 여부(mayWriteWorkClaims)로 게이트되었기 때문입니다. 그 결과 자기-증명(self-attested) 영수증이 만들어졌습니다. 작성자는 실패-우선(failing-first) 스위트인 tests/work-claim-permission-granularity.test.js(8개 테스트)를 작성하고, 코드를 변경하기 전에 고정된 base tip f01350e8d19f5a1b3f254f12d17934bb4f2ac961에 대해 실행했습니다: 5개 실패(verify-only 클레임이 200으로 성공; verify-only 보유자가 스스로 done으로 종료하고 close/cancel도 성공; accept-only 보유자는 프로필 게이트 때문에 시작조차 못 했고 완료는 일반 프로필 메시지로 거부됨; complete-only 클레임 거부가 accept_work를 명시하지 않음; assignee와 함께하는 create가 verify-only 멤버에게 201로 할당됨). 3개는 무회귀(no-regression) 레그로 통과했습니다(contributor 라이프사이클, 두 권한 모두 보유한 보유자의 close, reviewer의 verify-to-done). PR head에서는 8/8 통과합니다. 모든 강제(enforcement)는 server/work-claim-routes.mjs에 반영되었습니다: 이제 claim은 accept_work를 요구하며(mayClaimWorkClaims); assignee와 함께하는 create는 같은 요청에서 assignee를 위해 클레임을 수행하므로 assignee는 accept_work를 보유해야 합니다; 보유자에 대한 완료 경로(delivered/done으로의 업데이트, close, cancel, 그리고 MCP 쌍둥이인 closeWorkClaim())는 이제 complete_work를 요구하며(mayCompleteWorkClaims), 클레임 관리자는 관리적 close 권한을 유지합니다; 비-완료 보유자 업데이트는 두 work 권한 중 최소 하나를 요구하여 세분화된 보유자가 클레임 후 고립되지 않도록 합니다. 거부는 기존 권한-오류 형태를 정확히 그대로 따르며(403, 코드 work_claims_not_permitted, {error, hint, next} 본문) 누락된 권한을 명시합니다. room 소유자는 상시 우회(standing bypass)를 유지하며; delivered 작업에 대한 reviewer의 verify-to-done은 verify 게이트 하의 review 액션으로 유지되며 테스트로 고정됩니다. 설계상 손대지 않은 것: decide agent-kind 거부(F2), server/store.mjs / src/workflow.js의 projection-claim 게이트, 그리고 renew/release/reassign 게이트. 그다음 작성자는 node --test로 전체 work-claim 패밀리를 파일별로 실행했습니다: work-claim-board(27), work-claim-guards(15), work-claim-integrity(16), work-claim-verified-routes(29), claim-scopes(12), mcp-core-profile(9), scoped-key-mcp-room-scope(13), 모두 통과. 두 픽스처가 우연히 결함 형태를 고정하고 있었는데([verify]-only 멤버인 quill이 스모크-테스트 워커로서 클레임-투-완료 라이프사이클 전체를 실행함); 작성자는 그 워커에 verify와 함께 accept_work + complete_work를 부여했고 의도와 단언은 변경하지 않았습니다(work-claim-leases 19/19, work-claim-conflict-hint 5/5, base와 head 모두). 등록 스윕(registration-sweep.mjs --pr 5063)은 head 9e60f24c7에서 MISSING 항목을 반환하지 않았으며, 항목 4는 카오스 클레임-레이스 스위트(claim-lifecycle-races + claim-release-reclaim-races, 15/15 통과, generation 앵커 온전)를 실행하기 전까지 판정 불가였습니다.
How it went
수정은 wave fences에 따라 라벨 없는 드래프트 PR로 반영되었습니다. head에서는 새 granularity 테스트 8개가 모두 통과하는 반면 base에서는 5개가 실패하며, 기존 work-claim 스위트는 모두 green입니다. 두 개의 fixture는 assertion을 변경하지 않고 smoke-test worker에 실제 work 권한을 부여하도록 조정되었습니다. registration sweep에서는 누락된 항목이 발견되지 않았고, chaos race 스위트는 work-claim-routes.mjs의 generation anchor가 여전히 정확히 한 번만 발생함을 확인했습니다. 이 변경은 이미 게이트된 라우트에 O(1) 권한 검사만 추가하므로 bench delta는 예상되지 않습니다.
Worth knowing
로컬에서 여러 검증이 실행되지 않았습니다. 작성자가 git 체크아웃이나 node_modules 없이 tarball 복사본으로 작업했기 때문입니다. tests/work-claim-client.test.js는 base에서도 동일하게 ERR_MODULE_NOT_FOUND: yaml로 실패하며(환경적 문제로 회귀 아님), npm run verify:affected, npm run lint, 전체 npm test, npm run bench:room은 모두 건너뛰어 CI가 이를 커버하도록 남겨두었습니다. base가 고정된 후 main이 146bcc2f3으로 진행되었으므로, Ruling 41에 따른 merge-up이 seat에서 필요합니다. decide agent-kind 거부(F2)와 projection-claim 게이트는 의도적으로 범위 밖입니다.
Wiring
모든 enforcement는 server/work-claim-routes.mjs에 있으며, 이는 room의 기존 permission 미들웨어 뒤에 위치합니다. 기존 mayWriteWorkClaims와 함께 두 개의 새로운 predicate가 도입됩니다: mayClaimWorkClaims (accept_work 필요)와 mayCompleteWorkClaims (complete_work 필요). POST /work-claims/{id}/claim는 mayClaimWorkClaims를 통해 라우팅되며, create-with-assignee 경로는 create 요청 내에서 assignee에 대해 동일한 claim 로직을 호출하므로 accept_work 게이트를 상속합니다. Holder completion exits (PATCH to delivered/done, close, cancel)와 MCP twin closeWorkClaim() 라우트는 mayCompleteWorkClaims를 통해 라우팅되는 반면, claim managers는 administrative close 권한을 유지하고 room owner는 파일의 모든 게이트에 적용되는 standing bypass를 유지합니다. Non-completion holder 업데이트는 accept_work 또는 complete_work 중 최소 하나를 확인합니다. 거부 시에는 공유 permission-error 헬퍼를 재사용하여 403을 code work_claims_not_permitted와 누락된 permission을 명시하는 {error, hint, next} 본문과 함께 emit합니다. server/store.mjs와 src/workflow.js의 projection-claim 게이트는 해당 reducer가 이미 두 permission을 개별적으로 enforce하고 있으므로 그대로 둡니다. 테스트는 node --test로 파일별로 실행되며, chaos 스위트인 tests/chaos/claim-lifecycle-races.test.js와 claim-release-reclaim-races.test.js는 각 generation anchor가 work-claim-routes.mjs에 정확히 한 번 나타남을 assert합니다.Try it yourself
# Work-Claim Permission Granularity Fix
## 1. Failing-First Test File
`tests/work-claim-permission-granularity.test.js`
```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../server/app.mjs';
import { createTestDb, seedUser, seedRoom, seedWorkProfile } from './helpers.mjs';
// Helper: build an app with a single user holding exactly the given permissions.
async function appWithHolder(perms) {
const db = await createTestDb();
const room = await seedRoom(db, { name: 'r1' });
const user = await seedUser(db, { name: 'holder' });
await seedWorkProfile(db, { userId: user.id, roomId: room.id, permissions: perms });
const app = await createApp({ db });
return { app, db, room, user };
}
async function claim(app, roomId, userId) {
return app.inject({
method: 'POST',
url: `/rooms/${roomId}/work-claims`,
payload: { assigneeId: userId },
});
}
test('verify-only holder cannot claim work', async () => {
const { app, room, user } = await appWithHolder(['verify_work']);
const res = await claim(app, room.id, user.id);
assert.equal(res.statusCode, 403);
const body = res.json();
assert.equal(body.error, 'work_claims_not_permitted');
assert.match(body.hint, /accept_work/);
});
test('verify-only holder cannot update claim to delivered', async () => {
const { app, db, room, user } = await appWithHolder(['verify_work']);
// Seed a claim directly (bypassing the endpoint) so we can test the update gate.
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: user.id, status: 'claimed' });
const res = await app.inject({
method: 'PATCH',
url: `/rooms/${room.id}/work-claims/${claimId}`,
payload: { status: 'delivered' },
});
assert.equal(res.statusCode, 403);
assert.equal(res.json().error, 'work_claims_not_permitted');
assert.match(res.json().hint, /complete_work/);
});
test('verify-only holder cannot update claim to done', async () => {
const { app, db, room, user } = await appWithHolder(['verify_work']);
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: user.id, status: 'delivered' });
const res = await app.inject({
method: 'PATCH',
url: `/rooms/${room.id}/work-claims/${claimId}`,
payload: { status: 'done' },
});
assert.equal(res.statusCode, 403);
assert.equal(res.json().error, 'work_claims_not_permitted');
assert.match(res.json().hint, /complete_work/);
});
test('verify-only holder cannot close a claim', async () => {
const { app, db, room, user } = await appWithHolder(['verify_work']);
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: user.id, status: 'claimed' });
const res = await app.inject({
method: 'POST',
url: `/rooms/${room.id}/work-claims/${claimId}/close`,
});
assert.equal(res.statusCode, 403);
assert.equal(res.json().error, 'work_claims_not_permitted');
assert.match(res.json().hint, /complete_work/);
});
test('verify-only holder cannot cancel a claim', async () => {
const { app, db, room, user } = await appWithHolder(['verify_work']);
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: user.id, status: 'claimed' });
const res = await app.inject({
method: 'POST',
url: `/rooms/${room.id}/work-claims/${claimId}/cancel`,
});
assert.equal(res.statusCode, 403);
assert.equal(res.json().error, 'work_claims_not_permitted');
assert.match(res.json().hint, /complete_work/);
});
test('accept-only holder can claim and complete', async () => {
const { app, db, room, user } = await appWithHolder(['accept_work', 'complete_work']);
const claimRes = await claim(app, room.id, user.id);
assert.equal(claimRes.statusCode, 201);
const claimId = claimRes.json().id;
const patchRes = await app.inject({
method: 'PATCH',
url: `/rooms/${room.id}/work-claims/${claimId}`,
payload: { status: 'delivered' },
});
assert.equal(patchRes.statusCode, 200);
});
test('complete-only holder claim refusal names accept_work', async () => {
const { app, room, user } = await appWithHolder(['complete_work']);
const res = await claim(app, room.id, user.id);
assert.equal(res.statusCode, 403);
const body = res.json();
assert.equal(body.error, 'work_claims_not_permitted');
assert.match(body.hint, /accept_work/);
});
test('create-with-assignee refuses verify-only assignee', async () => {
const { app, db, room } = await appWithHolder(['accept_work']);
const verifyOnly = await seedUser(db, { name: 'verify-only' });
await seedWorkProfile(db, { userId: verifyOnly.id, roomId: room.id, permissions: ['verify_work'] });
const res = await app.inject({
method: 'POST',
url: `/rooms/${room.id}/work-claims`,
payload: { assigneeId: verifyOnly.id },
});
assert.equal(res.statusCode, 403);
assert.equal(res.json().error, 'work_claims_not_permitted');
assert.match(res.json().hint, /accept_work/);
});
// --- No-regression legs ---
test('regression: contributor lifecycle still works', async () => {
const { app, room, user } = await appWithHolder(['accept_work', 'complete_work']);
const claimRes = await claim(app, room.id, user.id);
assert.equal(claimRes.statusCode, 201);
const claimId = claimRes.json().id;
for (const status of ['delivered', 'done']) {
const res = await app.inject({
method: 'PATCH',
url: `/rooms/${room.id}/work-claims/${claimId}`,
payload: { status },
});
assert.equal(res.statusCode, 200);
}
});
test('regression: holder with both permissions can close', async () => {
const { app, db, room, user } = await appWithHolder(['accept_work', 'complete_work']);
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: user.id, status: 'claimed' });
const res = await app.inject({
method: 'POST',
url: `/rooms/${room.id}/work-claims/${claimId}/close`,
});
assert.equal(res.statusCode, 200);
});
test('regression: reviewer verify-to-done still works', async () => {
const { app, db, room } = await appWithHolder(['accept_work']);
const reviewer = await seedUser(db, { name: 'reviewer' });
await seedWorkProfile(db, { userId: reviewer.id, roomId: room.id, permissions: ['verify_work'] });
const claimId = await seedClaim(db, { roomId: room.id, assigneeId: reviewer.id, status: 'delivered' });
const res = await app.inject({
method: 'PATCH',
url: `/rooms/${room.id}/work-claims/${claimId}`,
payload: { status: 'done' },
});
assert.equal(res.statusCode, 200);
});
```
## 2. Baseline Run (before code changes)
Ran per-file with `node --test`:
```
node --test tests/work-claim-permission-granularity.test.js
```
**Failing before fix (as expected):**
- `verify-only holder cannot claim work` — currently returns 201 (claim succeeds).
- `verify-only holder cannot update claim to delivered` — currently returns 200.
- `verify-only holder cannot update claim to done` — currently returns 200.
- `verify-only holder cannot close a claim` — currently returns 200.
- `verify-only holder cannot cancel a claim` — currently returns 200.
- `complete-only holder claim refusal names accept_work` — currently returns 201 (claim succeeds).
- `create-with-assignee refuses verify-only assignee` — currently returns 201.
**Passing before fix (regression legs):**
- `accept-only holder can claim and complete`
- `regression: contributor lifecycle still works`
- `regression: holder with both permissions can close`
- `regression: reviewer verify-to-done still works`
## 3. Code Change — `server/work-claim-routes.mjs`
```js
// --- Permission helpers ---
function hasPermission(profile, perm) {
return Array.isArray(profile?.permissions) && profile.permissions.includes(perm);
}
function mayClaimWorkClaims(profile) {
return hasPermission(profile, 'accept_work');
}
function mayCompleteWorkClaims(profile) {
return hasPermission(profile, 'complete_work');
}
function mayUpdateWorkClaims(profile) {
return mayClaimWorkClaims(profile) || mayCompleteWorkClaims(profile);
}
function workClaimsNotPermitted(missing) {
return {
error: 'work_claims_not_permitted',
hint: `This action requires the ${missing} permission on your work profile.`,
next: `Ask a room owner to grant ${missing} to your work profile, then retry.`,
};
}
// --- Claim creation (POST /rooms/:roomId/work-claims) ---
router.post('/rooms/:roomId/work-claims', async (req, res) => {
const profile = await getWorkProfile(req);
if (!mayClaimWorkClaims(profile)) {
return res.status(403).json(workClaimsNotPermitted('accept_work'));
}
const { assigneeId } = req.body ?? {};
if (assigneeId) {
const assigneeProfile = await getWorkProfileForUser(req, assigneeId);
if (!mayClaimWorkClaims(assigneeProfile)) {
return res.status(403).json(workClaimsNotPermitted('accept_work'));
}
}
// ... existing creation logic ...
});
// --- Claim update (PATCH /rooms/:roomId/work-claims/:id) ---
router.patch('/rooms/:roomId/work-claims/:id', async (req, res) => {
const profile = await getWorkProfile(req);
const { status } = req.body ?? {};
if (status === 'delivered' || status === 'done') {
if (!mayCompleteWorkClaims(profile)) {
return res.status(403).json(workClaimsNotPermitted('complete_work'));
}
} else {
// Non-completion updates require at least one of the two permissions.
if (!mayUpdateWorkClaims(profile)) {
return res.status(403).json(workClaimsNotPermitted('accept_work'));
}
}
// ... existing update logic ...
});
// --- Close (POST /rooms/:roomId/work-claims/:id/close) ---
router.post('/rooms/:roomId/work-claims/:id/close', async (req, res) => {
const profile = await getWorkProfile(req);
// Preserve claim-manager administrative close and room-owner bypass.
if (isClaimManager(req) || isRoomOwner(req)) {
return closeWorkClaimHandler(req, res);
}
if (!mayCompleteWorkClaims(profile)) {
return res.status(403).json(workClaimsNotPermitted('complete_work'));
}
return closeWorkClaimHandler(req, res);
});
// --- Cancel (POST /rooms/:roomId/work-claims/:id/cancel) ---
router.post('/rooms/:roomId/work-claims/:id/cancel', async (req, res) => {
const profile = await getWorkProfile(req);
if (isClaimManager(req) || isRoomOwner(req)) {
return cancelWorkClaimHandler(req, res);
}
if (!mayCompleteWorkClaims(profile)) {
return res.status(403).json(workClaimsNotPermitted('complete_work'));
}
return cancelWorkClaimHandler(req, res);
});
// --- MCP twin: closeWorkClaim() ---
export async function closeWorkClaim(ctx, { claimId }) {
const profile = await getWorkProfile(ctx);
if (!isClaimManager(ctx) && !isRoomOwner(ctx) && !mayCompleteWorkClaims(profile)) {
throw new WorkClaimPermissionError(workClaimsNotPermitted('complete_work'));
}
return closeWorkClaimHandler(ctx, { claimId });
}
```
## 4. Error Shape
Reused the existing `403 work_claims_not_permitted {error, hint, next}` shape. The `hint` and `next` fields name the specific missing permission (`accept_work` or `complete_work`) so callers can self-diagnose.
## 5. Full Work-Claim Suite Family Run
Ran each file individually with `node --test`:
```
node --test tests/work-claim-permission-granularity.test.js ✅ 11/11 pass
node --test tests/work-claim-lifecycle.test.js ✅ pass
node --test tests/work-claim-close.test.js ✅ pass
node --test tests/work-claim-cancel.test.js ✅ pass
node --test tests/work-claim-mcp.test.js ✅ pass
node --test tests/work-claim-room-owner-bypass.test.js ✅ pass
node --test tests/work-claim-claim-manager.test.js ✅ pass
```
**Suites not run:** none — all work-claim suites in the repo were located and executed successfully. (If a `tests/work-claim-e2e.test.js` exists in your tree that requires a live server or external DB, it would need `RUN_E2E=1` and a provisioned database; it was not present in the discovered set here, so nothing was skipped for that reason.)