Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and match each card to a member, via two transports sharing one ingest core. Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in native-HeatSync connection's ?z log through the existing hardened executor and parses it (firmware-characterized, best-effort ring buffer). Webhook: /api/door/inbound/[connection] accepts pushed event JSON authenticated by a per-connection bearer secret from the vault. Both dedupe on (connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an inbound config panel to /door/manage. Phase 5 (migration 054): admins define api_buttons that fire a configured HTTP call (full verbs + headers + body) when an authorized member presses them, through the same hardened egress (now shared between callDoor and the new callApi). Each button carries a required_permission (default the new apicall.invoke code); members see only buttons they may press. Builder at /door/buttons with a door-template preset; members press from /doors. Every press is recorded in api_call_log with secrets redacted. Both subsystems are inert until an admin enables inbound on a connection or creates a button. 624 unit + 50 integration tests; migrations applied top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Door epic Phase 5 (migration 054) against real Postgres. Proves the RLS the
|
||||
// unit tests cannot: api_buttons CRUD is door.manage-only (members have no read,
|
||||
// since a button's url/headers/secret_ref are operator config), api_call_log is
|
||||
// immutable from the client (service-only writes), tenant isolation holds, and
|
||||
// the new apicall.invoke permission is seeded/backfilled for board.
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { dbReady, execService, execAsUser, rowsAsUser, rows, seedSpace, seedMember, dropSpace, uuid } from './db'
|
||||
|
||||
const d = describe.skipIf(!dbReady)
|
||||
|
||||
function seedButton(spaceId: string, requiredPerm = 'apicall.invoke'): string {
|
||||
const id = uuid()
|
||||
execService(
|
||||
`insert into api_buttons (id, space_id, label, base_url, pinned_host, required_permission)
|
||||
values ('${id}','${spaceId}','itest btn','https://device.lan/','device.lan','${requiredPerm}');`,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
d('api buttons (054)', () => {
|
||||
const spaces: string[] = []
|
||||
function space(): string {
|
||||
const { spaceId } = seedSpace()
|
||||
spaces.push(spaceId)
|
||||
return spaceId
|
||||
}
|
||||
afterAll(() => spaces.forEach(dropSpace))
|
||||
|
||||
it('seeds apicall.invoke for board on a new space', () => {
|
||||
const sid = space()
|
||||
const board = seedMember(sid, { role: 'board' })
|
||||
const has = rows<{ ok: boolean }>(
|
||||
`select public.user_has_permission('${board.userId}','${sid}','apicall.invoke') as ok`,
|
||||
)
|
||||
expect(has[0].ok).toBe(true)
|
||||
})
|
||||
|
||||
it('door.manage can insert + read buttons; a plain member cannot read', () => {
|
||||
const sid = space()
|
||||
const admin = seedMember(sid, { role: 'admin' })
|
||||
const member = seedMember(sid, { role: 'member' })
|
||||
|
||||
const ins = execAsUser(
|
||||
admin.userId,
|
||||
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sid}','front door','https://d.lan/','d.lan');`,
|
||||
)
|
||||
expect(ins.ok).toBe(true)
|
||||
|
||||
const adminSees = rowsAsUser(admin.userId, `select id from api_buttons where space_id='${sid}'`)
|
||||
expect(adminSees.length).toBe(1)
|
||||
|
||||
// Members have no RLS read on definitions (they get a curated list via the
|
||||
// service-client action instead).
|
||||
const memberSees = rowsAsUser(member.userId, `select id from api_buttons where space_id='${sid}'`)
|
||||
expect(memberSees).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a plain member cannot insert a button; an admin cannot write into another space', () => {
|
||||
const sidA = space()
|
||||
const adminA = seedMember(sidA, { role: 'admin' })
|
||||
const member = seedMember(sidA, { role: 'member' })
|
||||
const sidB = space()
|
||||
|
||||
const memberWrite = execAsUser(
|
||||
member.userId,
|
||||
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sidA}','x','https://d/','d');`,
|
||||
)
|
||||
expect(memberWrite.ok).toBe(false)
|
||||
|
||||
const crossWrite = execAsUser(
|
||||
adminA.userId,
|
||||
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sidB}','x','https://d/','d');`,
|
||||
)
|
||||
expect(crossWrite.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps api_call_log immutable from the client (no INSERT policy)', () => {
|
||||
const sid = space()
|
||||
const admin = seedMember(sid, { role: 'admin' })
|
||||
const btn = seedButton(sid)
|
||||
const r = execAsUser(
|
||||
admin.userId,
|
||||
`insert into api_call_log (space_id, button_id, action, success) values ('${sid}','${btn}','invoke',true);`,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('lets door.manage read the api_call_log; a plain member cannot', () => {
|
||||
const sid = space()
|
||||
const admin = seedMember(sid, { role: 'admin' })
|
||||
const member = seedMember(sid, { role: 'member' })
|
||||
const btn = seedButton(sid)
|
||||
execService(
|
||||
`insert into api_call_log (space_id, button_id, action, success) values ('${sid}','${btn}','invoke',true);`,
|
||||
)
|
||||
expect(rowsAsUser(admin.userId, `select id from api_call_log where space_id='${sid}'`).length).toBe(1)
|
||||
expect(rowsAsUser(member.userId, `select id from api_call_log where space_id='${sid}'`)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enforces the method CHECK constraint', () => {
|
||||
const sid = space()
|
||||
const bad = execService(
|
||||
`insert into api_buttons (space_id, label, base_url, pinned_host, method) values ('${sid}','x','https://d/','d','TRACE');`,
|
||||
)
|
||||
expect(bad.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// Door epic Phase 4 (migration 053) against real Postgres. Proves the inbound
|
||||
// ingest invariants the unit tests cannot: the partial-unique dedup index, the
|
||||
// fact that action rows (NULL dedupe_key) stay unconstrained, that
|
||||
// door_access_log remains immutable from the client (service-only writes), and
|
||||
// that inbound config is door.manage-gated through RLS. Same role-simulation
|
||||
// harness as the other integration tests.
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { dbReady, execService, execAsUser, rowsAsUser, seedSpace, seedMember, dropSpace, uuid } from './db'
|
||||
|
||||
const d = describe.skipIf(!dbReady)
|
||||
|
||||
function seedConnection(spaceId: string): string {
|
||||
const id = uuid()
|
||||
execService(
|
||||
`insert into door_connections (id, space_id, name, adapter, base_url, pinned_host)
|
||||
values ('${id}','${spaceId}','itest door','native_heatsync','http://10.0.0.9/','10.0.0.9');`,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
d('door inbound ingest (053)', () => {
|
||||
const spaces: string[] = []
|
||||
function space(): string {
|
||||
const { spaceId } = seedSpace()
|
||||
spaces.push(spaceId)
|
||||
return spaceId
|
||||
}
|
||||
afterAll(() => spaces.forEach(dropSpace))
|
||||
|
||||
it('dedupes ingested rows on (connection_id, dedupe_key)', () => {
|
||||
const sid = space()
|
||||
const conn = seedConnection(sid)
|
||||
const first = execService(
|
||||
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
|
||||
values ('${sid}','${conn}','entry',true,'hs:5:G:12345:');`,
|
||||
)
|
||||
expect(first.ok).toBe(true)
|
||||
// Same connection + same dedupe_key: the partial unique index rejects it,
|
||||
// which is what makes a re-poll / webhook retry a safe no-op.
|
||||
const dup = execService(
|
||||
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
|
||||
values ('${sid}','${conn}','entry',true,'hs:5:G:12345:');`,
|
||||
)
|
||||
expect(dup.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves action rows (NULL dedupe_key) unconstrained', () => {
|
||||
const sid = space()
|
||||
const conn = seedConnection(sid)
|
||||
// Two app-action rows with NULL dedupe_key on the same connection must both
|
||||
// insert (NULLs are distinct in the unique index), so existing grant/open/
|
||||
// self_entry auditing is unaffected by the dedup index.
|
||||
const a = execService(
|
||||
`insert into door_access_log (space_id, connection_id, action, success) values ('${sid}','${conn}','open',true);`,
|
||||
)
|
||||
const b = execService(
|
||||
`insert into door_access_log (space_id, connection_id, action, success) values ('${sid}','${conn}','open',true);`,
|
||||
)
|
||||
expect(a.ok && b.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps door_access_log immutable from the client (no INSERT policy)', () => {
|
||||
const sid = space()
|
||||
const conn = seedConnection(sid)
|
||||
const admin = seedMember(sid, { role: 'admin' })
|
||||
// Even an admin (door.manage via the role shortcut) cannot write the audit
|
||||
// log directly; only the service-client ingest paths insert.
|
||||
const r = execAsUser(
|
||||
admin.userId,
|
||||
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
|
||||
values ('${sid}','${conn}','entry',true,'wh:forged');`,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('lets door.manage read the log and configure inbound; a plain member cannot', () => {
|
||||
const sid = space()
|
||||
const adminUser = seedMember(sid, { role: 'admin' })
|
||||
const member = seedMember(sid, { role: 'member' })
|
||||
const conn = seedConnection(sid)
|
||||
execService(
|
||||
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
|
||||
values ('${sid}','${conn}','entry',true,'hs:1:G:7:');`,
|
||||
)
|
||||
|
||||
const adminSees = rowsAsUser<{ action: string }>(
|
||||
adminUser.userId,
|
||||
`select action from door_access_log where space_id='${sid}'`,
|
||||
)
|
||||
expect(adminSees.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const memberSees = rowsAsUser(member.userId, `select action from door_access_log where space_id='${sid}'`)
|
||||
expect(memberSees).toHaveLength(0)
|
||||
|
||||
// door.manage (admin shortcut) can flip inbound_enabled; a plain member's
|
||||
// UPDATE is filtered out by RLS (zero rows affected, no error raised).
|
||||
const adminUpd = execAsUser(
|
||||
adminUser.userId,
|
||||
`update door_connections set inbound_enabled=true where id='${conn}';`,
|
||||
)
|
||||
expect(adminUpd.ok).toBe(true)
|
||||
expect(rowsAsUser<{ inbound_enabled: boolean }>(adminUser.userId, `select inbound_enabled from door_connections where id='${conn}'`)[0].inbound_enabled).toBe(true)
|
||||
|
||||
execAsUser(member.userId, `update door_connections set inbound_enabled=false where id='${conn}';`)
|
||||
expect(rowsAsUser<{ inbound_enabled: boolean }>(adminUser.userId, `select inbound_enabled from door_connections where id='${conn}'`)[0].inbound_enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user