feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel: - DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the same opa.decide) — DM capped at 2, add-participant requires member/group-admin, governed self-join for membership threads. Real OPA swaps in unchanged. - Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would. - PolicyDeniedFilter maps fail-closed denials to HTTP 403. Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA policy + an opaque thread attribute): - MessageService.addParticipant (governed membership by userId), governed openThread self-join (scoped to threads with a membership attribute), parentInteractionId on send (reply link), and a generic listThreads. - REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants; socket add_participant + membership/parentInteractionId. ensureParticipant gains a role. Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join / listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed join. 175 unit tests + all smokes green; kernel free of dm/group literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DevOpaPort } from './dev-opa.port';
|
||||
|
||||
const opa = new DevOpaPort();
|
||||
|
||||
describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
||||
it('allows every existing action by default (thread create/read, message send)', async () => {
|
||||
for (const action of ['iios.thread.create', 'iios.thread.read', 'iios.message.send', undefined]) {
|
||||
expect((await opa.decide({ action })).allow).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('DM is capped at two: adding a 2nd is allowed, a 3rd is denied', async () => {
|
||||
const add = (participantCount: number) =>
|
||||
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount });
|
||||
expect((await add(1)).allow).toBe(true); // 1 → adding the 2nd
|
||||
const third = await add(2); // 2 → adding a 3rd
|
||||
expect(third.allow).toBe(false);
|
||||
expect(third.obligations[0]?.reason).toMatch(/two people/);
|
||||
});
|
||||
|
||||
it('adding a participant requires the caller be a member', async () => {
|
||||
const d = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'NONE', participantCount: 3 });
|
||||
expect(d.allow).toBe(false);
|
||||
expect(d.obligations[0]?.reason).toMatch(/member/);
|
||||
});
|
||||
|
||||
it('group add/remove requires ADMIN', async () => {
|
||||
const asMember = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'MEMBER', participantCount: 3 });
|
||||
expect(asMember.allow).toBe(false);
|
||||
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||
const asAdmin = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'ADMIN', participantCount: 3 });
|
||||
expect(asAdmin.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('self-join is governed only on membership threads', async () => {
|
||||
// generic / support thread (no membership attr) → open join, unchanged
|
||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||
// a membership (chat) thread → existing member allowed, stranger denied
|
||||
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: true })).allow).toBe(true);
|
||||
const stranger = await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false });
|
||||
expect(stranger.allow).toBe(false);
|
||||
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user