From 59e9fcbd4f660dcfb23371acdcbbda5bbfdecee5 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 20:31:43 -0700 Subject: [PATCH] feat(door): P2 connections + SSRF-safe executor + native adapter + audit Migration 035 adds door_connections (per-space controller integration: secret_ref -> AES-256-GCM secrets vault, never a plaintext password; pinned_host SSRF pin; native_heatsync|generic_http adapter; allow_member_self_entry opt-in for phase 3) and door_access_log (append-only, secrets redacted, service-client-only writes; immutable). RLS additive/default-deny: connections CRUD = door.manage; log SELECT = door.manage/operate. Safety core is pure and heavily unit-tested (door-logic, suite 409): validateDoorTarget (request host must equal the pinned host; metadata/link-local always blocked; http(s) only), the firmware-verified HeatSync encoders with fixed-width zero-padding (slot 3 / perm 3 / tag 8 / 4-char password), generic template substitution, and redactDoorSecrets. lib/door/executor.ts is the single hardened egress: re-validates the pin, refuses redirects, caps time and body, redacts before any audit write. The door password is read from the secrets vault and decrypted server-side only, never returned or logged. /door/manage (door.manage) configures connections, picks a vault secret, and runs a safe status-only test. Sidebar Admin link added; "Learn" moved before "People" per request. Docs updated. --- DB_SCHEMA_MAP.md | 5 +- __tests__/door-logic.test.ts | 94 +++++- app/(app)/door/manage/door-manage-client.tsx | 260 ++++++++++++++++ app/(app)/door/manage/page.tsx | 23 ++ components/app-sidebar.tsx | 15 +- docs/API_REFERENCE.md | 4 + docs/ARCHITECTURE.md | 25 +- docs/DATABASE_SCHEMA.md | 3 +- lib/actions/door.ts | 310 +++++++++++++++++++ lib/actions/index.ts | 3 + lib/door-guard.ts | 31 ++ lib/door-logic.ts | 144 +++++++++ lib/door/executor.ts | 68 ++++ lib/validations.ts | 43 +++ scripts/035_door_connections.sql | 95 ++++++ scripts/schema.sql | 79 +++++ types/database.ts | 12 + 17 files changed, 1203 insertions(+), 11 deletions(-) create mode 100644 app/(app)/door/manage/door-manage-client.tsx create mode 100644 app/(app)/door/manage/page.tsx create mode 100644 lib/actions/door.ts create mode 100644 lib/door-guard.ts create mode 100644 lib/door/executor.ts create mode 100644 scripts/035_door_connections.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index a06e88a..1356854 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -400,10 +400,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 032 | `classes`, `class_sessions`, `class_signups` + `classes.manage` / `classes.instruct` permissions. Additive default-deny RLS; signup/cancel via validated service-client action (no INSERT/DELETE policy); one non-cancelled signup per member+session; optional cert-on-completion via the normal certifications path | | 033 | `equipment`, `equipment_reservations` + `equipment.manage` permission. Additive default-deny RLS; reserve/cancel via validated service-client action (no INSERT/DELETE policy) enforcing status + no-overlap + required-cert with manager override | | 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls | +| 035 | `door_connections`, `door_access_log` (Door epic P2). Password via secrets vault (secret_ref), `pinned_host` SSRF pin, hardened executor. Connections CRUD = door.manage; log SELECT = door.manage/operate, service-client-only writes | --- -## Tables added by migrations 016-034 (quick map) +## Tables added by migrations 016-035 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -430,6 +431,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `equipment` | space_id, name, description, location, status (available/maintenance/retired), required_certification_id -> certifications, asset_tag, is_active | Tool/equipment registry. SELECT = `equipment.manage` (all) or member (`is_active`); writes = `equipment.manage` | | `equipment_reservations` | equipment_id, space_id, member_id, starts_at, ends_at, status (reserved/cancelled/completed), notes | Time-window reservation. SELECT = `equipment.manage` (all) or member (own); UPDATE = `equipment.manage`; NO INSERT/DELETE policy (reserve/cancel via validated service-client action; no-overlap + required-cert enforced there) | | `member_cards` | space_id, member_id, card_uid (credential), card_type rfid/nfc, label, is_active | RFID/NFC card association. SELECT/writes = `door.manage` only; member self-view is masked (server action, never raw UID) | +| `door_connections` | space_id, name, adapter (native_heatsync/generic_http), base_url, pinned_host (SSRF pin), auth_mode, secret_ref -> secrets, verbs jsonb, allow_member_self_entry, is_enabled | Door controller integration. CRUD = `door.manage`. Password lives in the encrypted secrets vault, never here | +| `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), occurred_at | Append-only door audit. SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`. diff --git a/__tests__/door-logic.test.ts b/__tests__/door-logic.test.ts index 1ab26d6..d7e0fbc 100644 --- a/__tests__/door-logic.test.ts +++ b/__tests__/door-logic.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from 'vitest' -import { last4, maskCardUid } from '@/lib/door-logic' +import { + last4, + maskCardUid, + isAlwaysBlockedHost, + validateDoorTarget, + encodeHeatSyncGrant, + encodeHeatSyncRevoke, + encodeHeatSyncControl, + applyTemplate, + redactDoorSecrets, +} from '@/lib/door-logic' describe('last4', () => { it('returns the last 4 chars, or the whole short value', () => { @@ -20,3 +30,85 @@ describe('maskCardUid', () => { expect(maskCardUid('')).toBe('••••') }) }) + +describe('isAlwaysBlockedHost', () => { + it('blocks cloud metadata and link-local', () => { + expect(isAlwaysBlockedHost('169.254.169.254')).toBe(true) + expect(isAlwaysBlockedHost('169.254.1.2')).toBe(true) + expect(isAlwaysBlockedHost('metadata.google.internal')).toBe(true) + expect(isAlwaysBlockedHost('fe80::1')).toBe(true) + expect(isAlwaysBlockedHost('0.0.0.0')).toBe(true) + }) + it('allows ordinary LAN hosts', () => { + expect(isAlwaysBlockedHost('192.168.1.50')).toBe(false) + expect(isAlwaysBlockedHost('10.0.0.5')).toBe(false) + expect(isAlwaysBlockedHost('door.local')).toBe(false) + }) +}) + +describe('validateDoorTarget', () => { + it('allows a LAN target that matches the pin', () => { + const r = validateDoorTarget('http://192.168.1.50/?o1&e=1234', '192.168.1.50') + expect(r.ok).toBe(true) + }) + it('rejects a host that does not match the pin (anti-redirect/SSRF)', () => { + const r = validateDoorTarget('http://evil.example.com/x', '192.168.1.50') + expect(r.ok).toBe(false) + }) + it('rejects metadata even if someone pins it', () => { + const r = validateDoorTarget('http://169.254.169.254/latest/meta-data/', '169.254.169.254') + expect(r.ok).toBe(false) + }) + it('rejects non-http protocols', () => { + expect(validateDoorTarget('file:///etc/passwd', 'x').ok).toBe(false) + expect(validateDoorTarget('gopher://192.168.1.50', '192.168.1.50').ok).toBe(false) + }) + it('pin may be entered with scheme/port/path and still matches host', () => { + const r = validateDoorTarget('http://192.168.1.50:8080/?9&e=1234', 'http://192.168.1.50:8080/door') + expect(r.ok).toBe(true) + }) +}) + +describe('encodeHeatSyncGrant', () => { + it('zero-pads slot/perm to 3 and tag to 8 (firmware byte offsets)', () => { + const r = encodeHeatSyncGrant({ slot: 7, permissionMask: 1, tagHex: 'abcd', password: '1234' }) + expect(r).toEqual({ ok: true, query: '?m007&p001&t0000abcd&e=1234' }) + }) + it('rejects out-of-range slot/perm and bad tag', () => { + expect(encodeHeatSyncGrant({ slot: 999, permissionMask: 1, tagHex: 'ab', password: '1' }).ok).toBe(false) + expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 500, tagHex: 'ab', password: '1' }).ok).toBe(false) + expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'xyz', password: '1' }).ok).toBe(false) + expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'ab', password: '' }).ok).toBe(false) + }) +}) + +describe('encodeHeatSyncRevoke / control', () => { + it('revoke zero-pads the slot and appends auth', () => { + expect(encodeHeatSyncRevoke(12, 'pass')).toEqual({ ok: true, query: '?r012&e=pass' }) + }) + it('control maps verbs and appends auth', () => { + expect(encodeHeatSyncControl('open1', 'pw')).toEqual({ ok: true, query: '?o1&e=pw' }) + expect(encodeHeatSyncControl('status', 'pw')).toEqual({ ok: true, query: '?9&e=pw' }) + expect(encodeHeatSyncControl('nope', 'pw').ok).toBe(false) + }) +}) + +describe('applyTemplate', () => { + it('substitutes and URL-encodes known vars, leaves unknowns', () => { + expect(applyTemplate('?card={tag}&slot={slot}', { tag: 'a b', slot: 5 })).toBe('?card=a%20b&slot=5') + expect(applyTemplate('?x={missing}', {})).toBe('?x={missing}') + }) +}) + +describe('redactDoorSecrets', () => { + it('scrubs the literal password and e= query value', () => { + expect(redactDoorSecrets('GET http://x/?m007&t00ab&e=1234 -> 200', '1234')) + .toBe('GET http://x/?m007&t00ab&e= -> 200') + }) + it('scrubs auth-ish params even without the password known', () => { + expect(redactDoorSecrets('?pw=hunter2&token=abc')).toBe('?pw=&token=') + }) + it('leaves unrelated text intact', () => { + expect(redactDoorSecrets('status 200 ok', null)).toBe('status 200 ok') + }) +}) diff --git a/app/(app)/door/manage/door-manage-client.tsx b/app/(app)/door/manage/door-manage-client.tsx new file mode 100644 index 0000000..36eaf9b --- /dev/null +++ b/app/(app)/door/manage/door-manage-client.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { DoorClosed, Plus } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { useConfirm } from '@/components/ui/confirm' +import { + createDoorConnection, + updateDoorConnection, + deleteDoorConnection, + testDoorConnection, +} from '@/lib/actions' + +type Conn = { + id: string + name: string + adapter: string + base_url: string + pinned_host: string + auth_mode: string + auth_param: string | null + secret_ref: string | null + verbs: Record | null + allow_member_self_entry: boolean + is_enabled: boolean +} +type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string } + +const input = 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary' + +function emptyDraft() { + return { + name: '', adapter: 'native_heatsync', base_url: '', pinned_host: '', + secret_ref: '', allow_member_self_entry: false, + grant: '', revoke: '', open: '', status: '', + } +} + +export function DoorManageClient({ + initial, + secrets, + log, +}: { + initial: unknown[] + secrets: { id: string; title: string }[] + log: unknown[] +}) { + const confirm = useConfirm() + const [conns, setConns] = useState(initial as Conn[]) + const [showNew, setShowNew] = useState(false) + const [d, setD] = useState(emptyDraft()) + const [busy, setBusy] = useState(false) + const rows = log as LogRow[] + + function buildVerbs(dr: typeof d) { + if (dr.adapter === 'native_heatsync') return {} + const v: Record = {} + if (dr.grant.trim()) v.grant = dr.grant.trim() + if (dr.revoke.trim()) v.revoke = dr.revoke.trim() + if (dr.open.trim()) v.open = dr.open.trim() + if (dr.status.trim()) v.status = dr.status.trim() + return v + } + + async function onCreate(e: React.FormEvent) { + e.preventDefault() + if (!d.name.trim() || !d.base_url.trim() || !d.pinned_host.trim()) { + return toast.error('Name, base URL and pinned host are required') + } + setBusy(true) + const res = await createDoorConnection({ + name: d.name.trim(), + adapter: d.adapter, + base_url: d.base_url.trim(), + pinned_host: d.pinned_host.trim(), + auth_mode: d.secret_ref ? 'query' : 'none', + auth_param: d.adapter === 'native_heatsync' ? 'e' : null, + secret_ref: d.secret_ref || null, + verbs: buildVerbs(d), + allow_member_self_entry: d.allow_member_self_entry, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + const id = (res as { data: { id: string } }).data.id + setConns(prev => [ + { + id, name: d.name.trim(), adapter: d.adapter, base_url: d.base_url.trim(), + pinned_host: d.pinned_host.trim(), auth_mode: d.secret_ref ? 'query' : 'none', + auth_param: d.adapter === 'native_heatsync' ? 'e' : null, secret_ref: d.secret_ref || null, + verbs: buildVerbs(d), allow_member_self_entry: d.allow_member_self_entry, is_enabled: true, + }, + ...prev, + ]) + setD(emptyDraft()) + setShowNew(false) + toast.success('Connection created') + } + + async function onToggleEnabled(c: Conn) { + const res = await updateDoorConnection({ connectionId: c.id, is_enabled: !c.is_enabled }) + if ('error' in res && res.error) return toast.error(res.error) + setConns(prev => prev.map(x => (x.id === c.id ? { ...x, is_enabled: !x.is_enabled } : x))) + } + + async function onToggleSelfEntry(c: Conn) { + const next = !c.allow_member_self_entry + if (next) { + const ok = await confirm({ + title: 'Allow member self-entry?', + description: 'Any active member will be able to trigger this door themselves. This is an elevated physical-security risk. Continue?', + confirmText: 'Enable self-entry', + destructive: true, + }) + if (!ok) return + } + const res = await updateDoorConnection({ connectionId: c.id, allow_member_self_entry: next }) + if ('error' in res && res.error) return toast.error(res.error) + setConns(prev => prev.map(x => (x.id === c.id ? { ...x, allow_member_self_entry: next } : x))) + } + + async function onTest(c: Conn) { + setBusy(true) + const res = await testDoorConnection({ connectionId: c.id }) + setBusy(false) + if ('error' in res && res.error) return toast.error(`Test failed: ${res.error}`) + toast.success(`Reachable (HTTP ${(res as { data: { status: number } }).data.status})`) + } + + async function onDelete(c: Conn) { + const ok = await confirm({ + title: 'Delete connection', + description: `"${c.name}" will be removed. Existing access-log entries are kept.`, + confirmText: 'Delete', + destructive: true, + }) + if (!ok) return + const res = await deleteDoorConnection({ connectionId: c.id }) + if ('error' in res && res.error) return toast.error(res.error) + setConns(prev => prev.filter(x => x.id !== c.id)) + toast.success('Deleted') + } + + return ( + <> + + Door access + + + +
+

+ A connection targets a door controller on your LAN. The shared password is read + from your encrypted Secrets vault, never stored here. The server only ever calls + the exact pinned host, never follows redirects, and records every action in the + access log with secrets redacted. +

+ + {showNew && ( +
+ setD({ ...d, name: e.target.value })} /> +
+ + + setD({ ...d, base_url: e.target.value })} /> + setD({ ...d, pinned_host: e.target.value })} /> +
+ {d.adapter === 'generic_http' && ( +
+ setD({ ...d, grant: e.target.value })} /> + setD({ ...d, revoke: e.target.value })} /> + setD({ ...d, open: e.target.value })} /> + setD({ ...d, status: e.target.value })} /> +
+ )} + + +
+ )} + + {conns.length === 0 ? ( + + + + No door connections + Add a controller connection to manage physical access. + + + ) : ( +
+ {conns.map(c => ( +
+
+
+ {c.name} + {c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'} + {!c.is_enabled && Disabled} + {c.allow_member_self_entry && self-entry on} +
+

+ {c.base_url} · pinned {c.pinned_host} · auth {c.secret_ref ? 'secret' : 'none'} +

+
+
+ + + + +
+
+ ))} +
+ )} + +
+

Access log

+ {rows.length === 0 ? ( +

No door activity recorded yet.

+ ) : ( +
    + {rows.map(r => ( +
  • + + {r.success ? 'ok' : 'fail'} + {' · '}{r.action}{r.detail ? ` · ${r.detail}` : ''} + + {new Date(r.occurred_at).toLocaleString()} +
  • + ))} +
+ )} +
+
+ + ) +} diff --git a/app/(app)/door/manage/page.tsx b/app/(app)/door/manage/page.tsx new file mode 100644 index 0000000..5b70034 --- /dev/null +++ b/app/(app)/door/manage/page.tsx @@ -0,0 +1,23 @@ +import { requireDoorManagerPage } from '@/lib/door-guard' +import { listDoorConnections, listSecretTitles, listDoorAccessLog } from '@/lib/actions' +import { DoorManageClient } from './door-manage-client' + +export const dynamic = 'force-dynamic' + +export default async function DoorManagePage() { + await requireDoorManagerPage() + + const [conns, secrets, log] = await Promise.all([ + listDoorConnections(), + listSecretTitles(), + listDoorAccessLog(), + ]) + + return ( + + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 2513239..0ce886b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -15,7 +15,7 @@ import { MessageSquare, Users, CreditCard, BookUser, Download, LogOut, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer, - ChevronDown, + ChevronDown, DoorClosed, } from 'lucide-react' interface NavLinkProps { @@ -173,6 +173,12 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + + + + + + @@ -186,12 +192,6 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa - - - - - - @@ -204,6 +204,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e05656a..9e3780b 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -928,3 +928,7 @@ Registry management is gated by `equipment.manage`; reserving needs only members - `reserveEquipment(input)` / `cancelReservation(input)` — any member. Validated service-client writes (`equipment_reservations` has no INSERT/DELETE policy): `reserveEquipment` runs `reservationEligibility` (status/active, future window, `hasConflict` no-overlap, required-cert) — a holder of `equipment.manage` gets the override and may pass `memberId` to book on someone's behalf; `cancelReservation` allows the owner or an `equipment.manage` holder. - `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view. - `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view). + +### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migration 035) +Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/`deleteMemberCard`/`listMemberCards` are `door.manage` (full UID); `getMyCards()` is the member's own MASKED view (service client, returns only count + last4, never the raw UID — `member_cards` has no member RLS SELECT). +`door.ts` (all `door.manage` except the read log): `createDoorConnection`/`updateDoorConnection`/`deleteDoorConnection`/`listDoorConnections`; `listSecretTitles()` (titles only, to pick a vault secret for the password). `testDoorConnection(input)` runs only the `status` verb through the hardened executor and writes one redacted audit row. `listDoorAccessLog()` is `door.manage` OR `door.operate`. Security model: the shared password is read from the AES-256-GCM secrets vault server-side and embedded in the request per the verified firmware (auth is a query param); `lib/door/executor.ts` is the single egress — it enforces `validateDoorTarget` (request host must equal the connection's pinned host; metadata/link-local always blocked; http(s) only), refuses redirects, caps time/body, and `redactDoorSecrets` scrubs the password before anything is logged. Native HeatSync verbs are encoded with the verified fixed-width zero-padding (slot 3 / perm 3 / tag 8 / `&e=` password); generic connections use admin `{slot}/{tag}/{pw}` templates. Live grant/revoke/open and the per-connection member self-entry are phase 3. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03db5a9..7c5a44f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -421,7 +421,8 @@ customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom forms + waivers + onboarding form step + per-space form slug + invite roles, `030` certifications + Instructor capability, `031` secrets SELECT honors `ops.secrets.read`, `032` classes + sessions + signups, `033` equipment + -reservations). +reservations, `034` member access cards + door permissions, `035` door +connections + access log). ### Forms & waivers (migrations 026-029; complete Phases 1-5) @@ -510,6 +511,28 @@ rule, and the required-certification gate (checked against the normal `member_certifications` data; an `equipment.manage` holder gets the override and may book on a member's behalf). No anonymous path. +### Door / access control (migrations 034-035; epic in progress) + +Phased; P1-P2 built. `member_cards` (034) associates RFID/NFC UIDs to +members; the UID is a credential (`door.manage`-only RLS, no member SELECT; +masked count+last4 self-view via a service-client action). `door.manage` / +`door.operate` permissions (group Access). `door_connections` (035) is a +per-space controller integration: the shared password is NOT stored on the +row — `secret_ref` points at the existing AES-256-GCM `secrets` vault and is +decrypted server-side only. `pinned_host` is the SSRF pin. Pure logic in +`lib/door-logic.ts` (SSRF `validateDoorTarget`, always-blocked metadata/ +link-local, native HeatSync encoders with the firmware-verified fixed-width +zero-padding, generic template substitution, secret redaction) is heavily +unit-tested. `lib/door/executor.ts` is the single hardened egress: it +re-validates the target against the pin, refuses redirects, caps time and +body, and redacts secrets before any audit write. `door_access_log` is an +append-only, secrets-redacted audit with no client write policy (validated +service-client executor only). `/door/manage` (door.manage) configures +connections, picks a vault secret, and runs a safe `status`-only test. +Phase 3 adds live grant/revoke/open and the per-connection member +self-entry (opt-in, elevated risk, off by default); phases 4-5 add inbound +log ingest and the universal API-call UI builder. No anonymous path. + --- ## 13. Known Limitations diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index b2e9f6e..2e638a0 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at); --- -## Migrations 014-034 (additions since the 13-table baseline) +## Migrations 014-035 (additions since the 13-table baseline) `scripts/schema.sql` is the canonical idempotent schema; each numbered migration is mirrored as a section in it. Tables/columns added: @@ -714,6 +714,7 @@ migration is mirrored as a section in it. Tables/columns added: | 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (all) or member (`is_active`), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = `classes.instruct`, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path | | 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path | | 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path | +| 035 | `door_connections` (per-space controller integration; shared password NOT stored -- `secret_ref` -> AES-256-GCM `secrets` vault, decrypted server-side only; `pinned_host` SSRF pin; adapter native_heatsync|generic_http; `allow_member_self_entry` opt-in) + `door_access_log` (append-only, secrets redacted). RLS additive + default-deny: door_connections CRUD = `door.manage`; door_access_log SELECT = `door.manage`/`door.operate`, NO write policy (service-client executor only; immutable). No new permission codes (door.* from 034). No anonymous path | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/lib/actions/door.ts b/lib/actions/door.ts new file mode 100644 index 0000000..ffe174d --- /dev/null +++ b/lib/actions/door.ts @@ -0,0 +1,310 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { + requireMember, + logActivity, + parseInput, + type Member, + type ServerSupabase, +} from '@/lib/auth-helpers' +import { + createDoorConnectionSchema, + updateDoorConnectionSchema, + doorConnectionIdSchema, +} from '@/lib/validations' +import { + encodeHeatSyncControl, + applyTemplate, + redactDoorSecrets, +} from '@/lib/door-logic' +import { callDoor } from '@/lib/door/executor' +import { decryptSecret } from '@/lib/secrets/crypto' + +type Gate = + | { ok: true; supabase: ServerSupabase; member: Member } + | { ok: false; error: string } + +async function requireDoorManager(): Promise { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { ok: false, error: auth.error } + const { member } = auth + const { data: allowed, error } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'door.manage', + }) + if (error) return { ok: false, error: error.message } + if (!allowed) return { ok: false, error: 'You do not have permission to manage door connections' } + return { ok: true, supabase, member } +} + +// Load + decrypt a connection's door password from the encrypted secrets +// vault. Service client: a door manager may legitimately not hold +// ops.secrets.read, and the plaintext must never reach the browser. Returns +// null when no secret is referenced (auth_mode 'none'). +async function resolveSecret( + admin: ReturnType, + spaceId: string, + secretRef: string | null, +): Promise { + if (!secretRef) return null + const { data } = await admin + .from('secrets') + .select('encryption_version, encrypted_value, value') + .eq('id', secretRef) + .eq('space_id', spaceId) + .maybeSingle() + if (!data) return null + if (data.encryption_version === 1 && data.encrypted_value) { + const raw = data.encrypted_value as unknown + const buf = + typeof raw === 'string' + ? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex') + : Buffer.from(raw as Uint8Array) + try { + return decryptSecret(buf, 1) + } catch { + return null + } + } + return (data.value as string | null) ?? null +} + +function isUniqueViolation(message: string): boolean { + return /duplicate key value|already exists|unique constraint/i.test(message) +} + +// ─── Connection CRUD (door.manage) ─────────────────────────────────────────── + +export async function createDoorConnection(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(createDoorConnectionSchema, input) + if (!v.ok) return { error: v.error } + const c = v.data + + if (c.secret_ref) { + const admin = createAdminClient() + const { data: sec } = await admin + .from('secrets') + .select('id') + .eq('id', c.secret_ref) + .eq('space_id', member.space_id) + .maybeSingle() + if (!sec) return { error: 'The referenced secret was not found in this space.' } + } + + const { data, error } = await supabase + .from('door_connections') + .insert({ + space_id: member.space_id, + name: c.name, + adapter: c.adapter, + base_url: c.base_url, + pinned_host: c.pinned_host, + auth_mode: c.auth_mode, + auth_param: c.auth_param ?? null, + secret_ref: c.secret_ref ?? null, + verbs: c.verbs, + allow_member_self_entry: c.allow_member_self_entry, + is_enabled: c.is_enabled, + created_by: member.id, + }) + .select('id') + .single() + if (error) { + if (isUniqueViolation(error.message)) return { error: 'A connection with that name already exists.' } + return { error: error.message } + } + + await logActivity(supabase, member, 'created', 'door_connection', data.id as string, c.name) + revalidatePath('/door/manage') + return { data: { id: data.id as string } } +} + +export async function updateDoorConnection(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(updateDoorConnectionSchema, input) + if (!v.ok) return { error: v.error } + const u = v.data + + if (u.secret_ref) { + const admin = createAdminClient() + const { data: sec } = await admin + .from('secrets') + .select('id') + .eq('id', u.secret_ref) + .eq('space_id', member.space_id) + .maybeSingle() + if (!sec) return { error: 'The referenced secret was not found in this space.' } + } + + const patch: Record = {} + for (const k of [ + 'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode', + 'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled', + ] as const) { + if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null + } + if (Object.keys(patch).length === 0) return { data: { id: u.connectionId } } + + const { error } = await supabase + .from('door_connections') + .update(patch) + .eq('id', u.connectionId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'updated', 'door_connection', u.connectionId) + revalidatePath('/door/manage') + return { data: { id: u.connectionId } } +} + +export async function deleteDoorConnection(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(doorConnectionIdSchema, input) + if (!v.ok) return { error: v.error } + + const { error } = await supabase + .from('door_connections') + .delete() + .eq('id', v.data.connectionId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'deleted', 'door_connection', v.data.connectionId) + revalidatePath('/door/manage') + return { data: { id: v.data.connectionId } } +} + +export async function listDoorConnections() { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const { data, error } = await supabase + .from('door_connections') + .select('id, name, adapter, base_url, pinned_host, auth_mode, auth_param, secret_ref, verbs, allow_member_self_entry, is_enabled, updated_at') + .eq('space_id', member.space_id) + .order('name', { ascending: true }) + if (error) return { error: error.message } + return { data: data ?? [] } +} + +// Available secrets (titles only) so the admin can pick one for a connection +// without the values ever being exposed here. +export async function listSecretTitles() { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { member } = gate + const admin = createAdminClient() + const { data, error } = await admin + .from('secrets') + .select('id, title') + .eq('space_id', member.space_id) + .order('title', { ascending: true }) + if (error) return { error: error.message } + return { data: (data ?? []).map(s => ({ id: s.id as string, title: s.title as string })) } +} + +// Safe connectivity check: runs the 'status' verb only. Never opens a door. +// Writes one redacted audit row. +export async function testDoorConnection(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(doorConnectionIdSchema, input) + if (!v.ok) return { error: v.error } + + const { data: conn } = await supabase + .from('door_connections') + .select('id, space_id, adapter, base_url, pinned_host, secret_ref, verbs, is_enabled') + .eq('id', v.data.connectionId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!conn) return { error: 'Connection not found' } + if (!conn.is_enabled) return { error: 'This connection is disabled.' } + + const admin = createAdminClient() + const password = await resolveSecret(admin, member.space_id, conn.secret_ref as string | null) + + let query: string + if (conn.adapter === 'native_heatsync') { + const enc = encodeHeatSyncControl('status', password ?? '') + if (!enc.ok) return { error: enc.reason } + query = enc.query + } else { + const tmpl = (conn.verbs as Record | null)?.status + if (!tmpl) return { error: 'This connection has no "status" verb template configured.' } + query = applyTemplate(tmpl, { pw: password ?? '' }) + } + + const result = await callDoor({ + url: (conn.base_url as string) + query, + pinnedHost: conn.pinned_host as string, + password, + }) + + const detail = result.ok + ? redactDoorSecrets(`status ${result.status}: ${result.snippet}`.slice(0, 1000), password) + : redactDoorSecrets(`failed: ${result.reason}`.slice(0, 1000), password) + + await admin.from('door_access_log').insert({ + space_id: member.space_id, + connection_id: conn.id, + actor_member_id: member.id, + action: 'test', + success: result.ok, + detail, + }) + + revalidatePath('/door/manage') + return result.ok + ? { data: { ok: true, status: result.status } } + : { error: result.reason } +} + +export async function listDoorAccessLog() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data: canManage } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'door.manage', + }) + let permitted = !!canManage + if (!permitted) { + const { data: canOp } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'door.operate', + }) + permitted = !!canOp + } + if (!permitted) return { error: 'You do not have permission to view the access log' } + + const { data, error } = await supabase + .from('door_access_log') + .select('id, connection_id, action, success, detail, occurred_at') + .eq('space_id', member.space_id) + .order('occurred_at', { ascending: false }) + .limit(200) + if (error) return { error: error.message } + return { data: data ?? [] } +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index f0596f2..4934e2b 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -59,3 +59,6 @@ export * from './equipment' // Member access cards (Door epic) export * from './member-cards' + +// Door connections + executor (Door epic) +export * from './door' diff --git a/lib/door-guard.ts b/lib/door-guard.ts new file mode 100644 index 0000000..f9f9066 --- /dev/null +++ b/lib/door-guard.ts @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' + +// Server-component guard for the /door/manage surface. Mirrors the other +// module guards: resolve the member, check door.manage via the same RPC the +// RLS uses, redirect non-managers before any data is fetched. The RLS +// independently enforces this. +export async function requireDoorManagerPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('id, space_id, role, user_id, display_name') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .single() + if (!member) redirect('/login') + + const { data: allowed } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: member.space_id, + perm: 'door.manage', + }) + if (!allowed) redirect('/dashboard') + + return { supabase, member } +} diff --git a/lib/door-logic.ts b/lib/door-logic.ts index 0fb5e91..e8dc0a3 100644 --- a/lib/door-logic.ts +++ b/lib/door-logic.ts @@ -12,3 +12,147 @@ export function maskCardUid(uid: string): string { if (uid.length <= 4) return '•'.repeat(uid.length || 4) return '•'.repeat(uid.length - 4) + uid.slice(-4) } + +// ─── SSRF guard (the dangerous part) ───────────────────────────────────────── +// A door connection deliberately targets a LAN device, so private ranges are +// allowed — but ONLY the exact host the admin pinned, and never the cloud +// metadata / link-local range. The executor additionally forbids redirects +// and caps response size/time; this pure function decides if a target URL is +// allowed to be called at all. + +// Always-blocked, regardless of pin: IMDS / link-local and unspecified. +export function isAlwaysBlockedHost(host: string): boolean { + const h = host.toLowerCase().replace(/^\[|\]$/g, '') + if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true + if (h.startsWith('169.254.')) return true // IPv4 link-local (includes IMDS) + if (h === '0.0.0.0' || h === '::' || h === '0:0:0:0:0:0:0:0') return true + if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS + if (h.startsWith('fe80:')) return true // IPv6 link-local + return false +} + +export type DoorTarget = + | { ok: true; url: string; host: string } + | { ok: false; reason: string } + +// Validate a fully-formed request URL against the connection's pinned host. +// pinnedHost is exactly what the admin entered (hostname or IP, optional +// :port is compared on the host only). The request host must equal the pin +// (case-insensitive) and must not be an always-blocked address. +export function validateDoorTarget(rawUrl: string, pinnedHost: string): DoorTarget { + let u: URL + try { + u = new URL(rawUrl) + } catch { + return { ok: false, reason: 'The request URL is not a valid URL.' } + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + return { ok: false, reason: 'Only http and https are allowed.' } + } + const host = u.hostname.toLowerCase() + const pin = pinnedHost.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0] + if (!pin) return { ok: false, reason: 'This connection has no pinned host.' } + if (isAlwaysBlockedHost(host)) { + return { ok: false, reason: 'That host is blocked (link-local / metadata).' } + } + if (host !== pin) { + return { ok: false, reason: `Request host "${host}" does not match the pinned host "${pin}".` } + } + return { ok: true, url: u.toString(), host } +} + +// ─── HeatSync native adapter encoders ──────────────────────────────────────── +// Firmware (zyphlar/Open_Access_Control_Ethernet.ino, re-verified 2026-05-17) +// parses FIXED-WIDTH ZERO-PADDED query params by byte offset: user slot = 3 +// digits, perm mask = 3 digits, tag = 8 hex chars, password = the shared +// secret. Auth is satisfied when "?e=" or "&e=" is present, so every +// privileged verb appends "&e=". Getting the widths wrong addresses the +// WRONG slot — so these are pure and unit-tested. + +function pad(n: number, width: number): string { + return String(n).padStart(width, '0') +} + +export type EncodeResult = { ok: true; query: string } | { ok: false; reason: string } + +function checkSlot(slot: number): string | null { + if (!Number.isInteger(slot) || slot < 0 || slot > 200) return 'Card slot must be an integer 0-200.' + return null +} + +export function encodeHeatSyncGrant(input: { + slot: number + permissionMask: number + tagHex: string + password: string +}): EncodeResult { + const slotErr = checkSlot(input.slot) + if (slotErr) return { ok: false, reason: slotErr } + if (!Number.isInteger(input.permissionMask) || input.permissionMask < 0 || input.permissionMask > 255) { + return { ok: false, reason: 'Permission mask must be an integer 0-255.' } + } + if (!/^[0-9a-fA-F]{1,8}$/.test(input.tagHex)) { + return { ok: false, reason: 'Tag must be 1-8 hexadecimal characters.' } + } + if (input.password.length < 1 || input.password.length > 16) { + return { ok: false, reason: 'Door password is missing or implausible.' } + } + const tag = input.tagHex.toLowerCase().padStart(8, '0') + return { + ok: true, + query: `?m${pad(input.slot, 3)}&p${pad(input.permissionMask, 3)}&t${tag}&e=${input.password}`, + } +} + +export function encodeHeatSyncRevoke(slot: number, password: string): EncodeResult { + const slotErr = checkSlot(slot) + if (slotErr) return { ok: false, reason: slotErr } + if (!password) return { ok: false, reason: 'Door password is missing.' } + return { ok: true, query: `?r${pad(slot, 3)}&e=${password}` } +} + +// verb: 'open1' | 'open2' | 'unlock' | 'lock' | 'arm' | 'disarm' | 'status' | 'log' +export function encodeHeatSyncControl(verb: string, password: string): EncodeResult { + if (!password) return { ok: false, reason: 'Door password is missing.' } + const map: Record = { + open1: '?o1', + open2: '?o2', + unlock: '?u', + lock: '?l', + disarm: '?1', + arm: '?2', + status: '?9', + log: '?z', + } + const base = map[verb] + if (!base) return { ok: false, reason: `Unknown door verb "${verb}".` } + return { ok: true, query: `${base}&e=${password}` } +} + +// ─── Generic adapter template substitution ─────────────────────────────────── +// A generic connection stores a path/query template per verb with {slot}, +// {tag}, {perm}, {door}, {pw} placeholders. Substitution is literal and the +// values are URL-encoded; unknown placeholders are left untouched so a +// misconfig fails loudly rather than silently calling something unintended. + +export function applyTemplate( + template: string, + vars: Record, +): string { + return template.replace(/\{(\w+)\}/g, (m, key) => + key in vars ? encodeURIComponent(String(vars[key])) : m, + ) +} + +// Before anything (URL, response snippet) is written to the audit log, scrub +// the shared door password: any literal occurrence, and any e=/&e= query +// value (the firmware auth param), become "". The password is a +// credential and must never land in door_access_log. +export function redactDoorSecrets(text: string, password?: string | null): string { + let out = text + if (password && password.length > 0) { + out = out.split(password).join('') + } + out = out.replace(/([?&](?:e|pw|password|api[_-]?key|token)=)[^&\s"']*/gi, '$1') + return out +} diff --git a/lib/door/executor.ts b/lib/door/executor.ts new file mode 100644 index 0000000..756d82a --- /dev/null +++ b/lib/door/executor.ts @@ -0,0 +1,68 @@ +// Server-only. The single hardened egress point for door-controller HTTP. +// Every door call funnels through here so the SSRF guard, no-redirect rule, +// timeout, response cap and secret redaction cannot be bypassed. The secret +// is already embedded in `url` by the caller (per the verified firmware, +// auth is a query param); it is never logged -- only a redacted snippet is +// ever returned. + +import { validateDoorTarget, redactDoorSecrets } from '@/lib/door-logic' + +export type DoorCallResult = + | { ok: true; status: number; snippet: string } + | { ok: false; reason: string; status?: number } + +const MAX_BODY = 4096 +const SNIPPET = 500 + +export async function callDoor(opts: { + url: string + pinnedHost: string + password?: string | null + timeoutMs?: number +}): Promise { + const guard = validateDoorTarget(opts.url, opts.pinnedHost) + if (!guard.ok) return { ok: false, reason: guard.reason } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000) + try { + const res = await fetch(guard.url, { + method: 'GET', + // Never chase a redirect: a 3xx could bounce us off the pinned host. + redirect: 'manual', + signal: controller.signal, + headers: { accept: 'text/plain, text/html' }, + cache: 'no-store', + }) + if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) { + return { + ok: false, + reason: 'The controller returned a redirect, which is refused (anti-SSRF).', + status: res.status || undefined, + } + } + + let received = '' + const reader = res.body?.getReader() + if (reader) { + const dec = new TextDecoder() + while (received.length < MAX_BODY) { + const { done, value } = await reader.read() + if (done) break + received += dec.decode(value, { stream: true }) + } + try { + await reader.cancel() + } catch { + /* ignore */ + } + } + const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password) + return { ok: res.ok, status: res.status, snippet } + } catch (e) { + const msg = e instanceof Error ? e.message : 'request failed' + return { ok: false, reason: redactDoorSecrets(msg, opts.password) } + } finally { + clearTimeout(timer) + } +} diff --git a/lib/validations.ts b/lib/validations.ts index 3601961..1cef09a 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -903,6 +903,49 @@ export const listMemberCardsSchema = z.object({ memberId: z.string().uuid('Invalid member ID'), }) +// ─── Door connections (Door epic P2) ───────────────────────────────────────── + +const httpUrl = z + .string() + .trim() + .max(2000) + .regex(/^https?:\/\/.+/i, 'Must be an http(s) URL') + +const doorAdapter = z.enum(['native_heatsync', 'generic_http']) +const doorAuthMode = z.enum(['none', 'query', 'header', 'bearer']) + +export const createDoorConnectionSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + adapter: doorAdapter.optional().default('generic_http'), + base_url: httpUrl, + pinned_host: z.string().min(1, 'A pinned host is required').max(255), + auth_mode: doorAuthMode.optional().default('none'), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + // generic adapter per-verb templates (validated as a flat string map) + verbs: z.record(z.string().max(2000)).optional().default({}), + allow_member_self_entry: z.boolean().optional().default(false), + is_enabled: z.boolean().optional().default(true), +}) + +export const updateDoorConnectionSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + name: z.string().min(1).max(200).optional(), + adapter: doorAdapter.optional(), + base_url: httpUrl.optional(), + pinned_host: z.string().min(1).max(255).optional(), + auth_mode: doorAuthMode.optional(), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + verbs: z.record(z.string().max(2000)).optional(), + allow_member_self_entry: z.boolean().optional(), + is_enabled: z.boolean().optional(), +}) + +export const doorConnectionIdSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), +}) + // ─── Generic ID schemas ────────────────────────────────────────────────────── export const uuidSchema = z.string().uuid('Invalid ID format') diff --git a/scripts/035_door_connections.sql b/scripts/035_door_connections.sql new file mode 100644 index 0000000..e01e66d --- /dev/null +++ b/scripts/035_door_connections.sql @@ -0,0 +1,95 @@ +-- ============================================================================= +-- 035: Door connections + access log (Door epic, phase 2) +-- ============================================================================= +-- door_connections is a per-space configured integration to a physical door +-- controller. SECURITY-CRITICAL: +-- +-- * The shared door password is NOT stored here. secret_ref references a +-- row in the existing AES-256-GCM `secrets` vault; the executor loads and +-- decrypts it server-side only and never returns/logs it. +-- * pinned_host is the SSRF pin: the executor will only ever call that exact +-- host (no redirects, size/time caps). A door is LAN-only, so private +-- ranges are allowed -- but only the pinned host, never metadata. +-- * adapter is 'native_heatsync' (verified query-string firmware) or +-- 'generic_http' (admin-supplied per-verb templates). +-- * allow_member_self_entry opts a connection into the member "buzz me in" +-- action (phase 3). Off by default; elevated risk, per-connection only. +-- +-- door_access_log is an append-only audit of every door action (who, what, +-- result), with secrets redacted before write. No client write policy: only +-- the validated service-client executor inserts rows. +-- +-- No new permission codes: door.manage / door.operate were added in 034. +-- RLS (additive, default-deny; the guarded surface): +-- * door_connections: all CRUD = door.manage. +-- * door_access_log SELECT = door.manage OR door.operate; NO +-- INSERT/UPDATE/DELETE policy (service-client executor only; immutable). +-- No anonymous path. +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then +-- CREATE. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.door_connections ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + adapter text NOT NULL DEFAULT 'generic_http' + CHECK (adapter IN ('native_heatsync','generic_http')), + base_url text NOT NULL CHECK (base_url ~ '^https?://'), + pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255), + auth_mode text NOT NULL DEFAULT 'none' + CHECK (auth_mode IN ('none','query','header','bearer')), + auth_param text, + secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL, + verbs jsonb NOT NULL DEFAULT '{}', + allow_member_self_entry boolean NOT NULL DEFAULT false, + is_enabled boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_door_connections_space ON public.door_connections (space_id); + +CREATE TABLE IF NOT EXISTS public.door_access_log ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + connection_id uuid REFERENCES public.door_connections(id) ON DELETE SET NULL, + actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + target_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + action text NOT NULL, + success boolean NOT NULL DEFAULT false, + detail text, + occurred_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_door_access_log_space ON public.door_access_log (space_id, occurred_at DESC); + +ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS door_connections_select ON public.door_connections; +DROP POLICY IF EXISTS door_connections_insert ON public.door_connections; +DROP POLICY IF EXISTS door_connections_update ON public.door_connections; +DROP POLICY IF EXISTS door_connections_delete ON public.door_connections; +CREATE POLICY door_connections_select ON public.door_connections FOR SELECT + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_insert ON public.door_connections FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_update ON public.door_connections FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_delete ON public.door_connections FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); + +DROP POLICY IF EXISTS door_access_log_select ON public.door_access_log; +CREATE POLICY door_access_log_select ON public.door_access_log FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'door.manage') + OR public.user_has_permission(auth.uid(), space_id, 'door.operate') + ); +-- No INSERT/UPDATE/DELETE policy: the validated service-client executor is +-- the only writer, and the audit trail is immutable. + +DROP TRIGGER IF EXISTS trg_door_connections_touch ON public.door_connections; +CREATE TRIGGER trg_door_connections_touch + BEFORE UPDATE ON public.door_connections + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/scripts/schema.sql b/scripts/schema.sql index 19b2aca..bad51e8 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -2374,3 +2374,82 @@ ON CONFLICT (space_id, subject, permission) DO NOTHING; INSERT INTO public.space_role_permissions (space_id, subject, permission) SELECT id, 'board', 'door.operate' FROM public.spaces ON CONFLICT (space_id, subject, permission) DO NOTHING; + + +-- ============================================================================= +-- 22. Door connections + access log (Door epic, phase 2) +-- (equivalent to scripts/035_door_connections.sql). +-- +-- door_connections: per-space controller integration. The shared door +-- password is NOT here -- secret_ref -> AES-256-GCM `secrets` vault, +-- decrypted server-side only. pinned_host is the SSRF pin (executor only +-- ever calls that exact host, no redirects, size/time caps). adapter +-- native_heatsync|generic_http; allow_member_self_entry opt-in (phase 3). +-- door_access_log: append-only, secrets redacted, service-client-only +-- writes (no client write policy; immutable audit). RLS additive/ +-- default-deny: door_connections CRUD = door.manage; door_access_log +-- SELECT = door.manage OR door.operate. No anonymous path. No new +-- permission codes (door.manage/door.operate from migration 034). +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.door_connections ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + adapter text NOT NULL DEFAULT 'generic_http' + CHECK (adapter IN ('native_heatsync','generic_http')), + base_url text NOT NULL CHECK (base_url ~ '^https?://'), + pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255), + auth_mode text NOT NULL DEFAULT 'none' + CHECK (auth_mode IN ('none','query','header','bearer')), + auth_param text, + secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL, + verbs jsonb NOT NULL DEFAULT '{}', + allow_member_self_entry boolean NOT NULL DEFAULT false, + is_enabled boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_door_connections_space ON public.door_connections (space_id); + +CREATE TABLE IF NOT EXISTS public.door_access_log ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + connection_id uuid REFERENCES public.door_connections(id) ON DELETE SET NULL, + actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + target_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + action text NOT NULL, + success boolean NOT NULL DEFAULT false, + detail text, + occurred_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_door_access_log_space ON public.door_access_log (space_id, occurred_at DESC); + +ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS door_connections_select ON public.door_connections; +DROP POLICY IF EXISTS door_connections_insert ON public.door_connections; +DROP POLICY IF EXISTS door_connections_update ON public.door_connections; +DROP POLICY IF EXISTS door_connections_delete ON public.door_connections; +CREATE POLICY door_connections_select ON public.door_connections FOR SELECT + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_insert ON public.door_connections FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_update ON public.door_connections FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY door_connections_delete ON public.door_connections FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); + +DROP POLICY IF EXISTS door_access_log_select ON public.door_access_log; +CREATE POLICY door_access_log_select ON public.door_access_log FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'door.manage') + OR public.user_has_permission(auth.uid(), space_id, 'door.operate') + ); + +DROP TRIGGER IF EXISTS trg_door_connections_touch ON public.door_connections; +CREATE TRIGGER trg_door_connections_touch + BEFORE UPDATE ON public.door_connections + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/types/database.ts b/types/database.ts index 4f0e225..413af6c 100644 --- a/types/database.ts +++ b/types/database.ts @@ -154,6 +154,18 @@ export type Database = { Update: { id?: string; space_id?: string; member_id?: string; card_uid?: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } Relationships: [] } + door_connections: { + Row: { id: string; space_id: string; name: string; adapter: string; base_url: string; pinned_host: string; auth_mode: string; auth_param: string | null; secret_ref: string | null; verbs: Json; allow_member_self_entry: boolean; is_enabled: boolean; created_by: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; name: string; adapter?: string; base_url: string; pinned_host: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; name?: string; adapter?: string; base_url?: string; pinned_host?: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } + door_access_log: { + Row: { id: string; space_id: string; connection_id: string | null; actor_member_id: string | null; target_member_id: string | null; action: string; success: boolean; detail: string | null; occurred_at: string } + Insert: { id?: string; space_id: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action: string; success?: boolean; detail?: string | null; occurred_at?: string } + Update: { id?: string; space_id?: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action?: string; success?: boolean; detail?: string | null; occurred_at?: string } + Relationships: [] + } activity_log: { Row: { action: string