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,55 @@
|
||||
-- =============================================================================
|
||||
-- 053: Door inbound access-log ingest (Door epic, phase 4)
|
||||
-- =============================================================================
|
||||
-- Phase 4 pulls real entry/denied events FROM a controller INTO door_access_log,
|
||||
-- via two transports that share one ingest core:
|
||||
-- * poll: a CRON_SECRET-guarded route reads each enabled connection's log
|
||||
-- verb (HeatSync ?z) through the existing hardened executor and parses it;
|
||||
-- * webhook: a per-connection public endpoint accepts normalized event JSON
|
||||
-- pushed by the controller or a relay, authenticated by a per-connection
|
||||
-- bearer secret kept in the AES-256-GCM vault.
|
||||
--
|
||||
-- This migration is ADDITIVE ONLY (no table re-create, no policy weakening):
|
||||
--
|
||||
-- door_access_log.dedupe_key
|
||||
-- A per-event idempotency token so a re-poll of the same ring-buffer slot
|
||||
-- or a webhook retry cannot double-insert. Partial-unique on
|
||||
-- (connection_id, dedupe_key) WHERE dedupe_key IS NOT NULL: existing
|
||||
-- action rows (grant/revoke/open/self_entry) leave it NULL and are
|
||||
-- unaffected; only ingested rows set it. The insert path uses ON CONFLICT
|
||||
-- DO NOTHING against this index.
|
||||
--
|
||||
-- door_connections.inbound_enabled (default false; opt-in, like
|
||||
-- allow_member_self_entry) gates the webhook endpoint for a connection.
|
||||
-- door_connections.inbound_secret_ref references the SAME secrets vault as
|
||||
-- secret_ref but holds the INBOUND webhook secret (a distinct credential
|
||||
-- from the outbound door password). It is loaded + compared server-side
|
||||
-- only and never returned to the browser.
|
||||
--
|
||||
-- PGRST embed note (the migration-048 outage class): door_connections now has
|
||||
-- two FKs to public.secrets (secret_ref + inbound_secret_ref). This does NOT
|
||||
-- create the ambiguous-junction shape that broke the auth embed -- that only
|
||||
-- happens when a table's PRIMARY KEY covers both FK columns; door_connections
|
||||
-- has a surrogate `id` PK. Nothing embeds secrets through PostgREST anyway
|
||||
-- (secrets are read service-side by id), and the auth path
|
||||
-- (space_members <-> spaces) is untouched. Safe.
|
||||
--
|
||||
-- No new permission codes: door.manage configures inbound; the cron + webhook
|
||||
-- writers are the validated service client (door_access_log already has no
|
||||
-- client INSERT policy and stays immutable from the client).
|
||||
--
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.door_access_log
|
||||
ADD COLUMN IF NOT EXISTS dedupe_key text;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_door_access_log_dedupe
|
||||
ON public.door_access_log (connection_id, dedupe_key)
|
||||
WHERE dedupe_key IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.door_connections
|
||||
ADD COLUMN IF NOT EXISTS inbound_enabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE public.door_connections
|
||||
ADD COLUMN IF NOT EXISTS inbound_secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- 054: Universal API-call UI builder (Door epic, phase 5)
|
||||
-- =============================================================================
|
||||
-- Admins (door.manage, which the catalog already scopes to "door integrations,
|
||||
-- buttons, and member cards") define named buttons that, when pressed by an
|
||||
-- authorized member, fire a configured HTTP call through the SAME hardened
|
||||
-- egress as the door executor (per-button pinned_host SSRF pin, always-block
|
||||
-- metadata/link-local, resolve-once-then-connect-by-IP, no redirects, time/body
|
||||
-- caps, secret from the AES vault injected server-side, redacted audit).
|
||||
--
|
||||
-- Authorization (the user-chosen model): managing buttons = door.manage; each
|
||||
-- button carries its own required_permission (any catalog code, default the new
|
||||
-- generic apicall.invoke) and a member may press only buttons whose permission
|
||||
-- they hold. One new permission code, additive + backfilled exactly like the
|
||||
-- door.* codes in 034: apicall.invoke (group Access). Manage stays door.manage.
|
||||
--
|
||||
-- api_buttons per-space button definition. The secret (if any) is NOT
|
||||
-- stored here: secret_ref -> the AES-256-GCM secrets vault,
|
||||
-- decrypted server-side only. RLS: all CRUD = door.manage
|
||||
-- (members get a curated list via a service-client action that
|
||||
-- filters by each button's required_permission).
|
||||
-- api_call_log append-only audit of every press (who/what/result), secrets
|
||||
-- redacted before write. SELECT = door.manage; NO client write
|
||||
-- policy (validated service-client invoker only; immutable).
|
||||
--
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
|
||||
-- CREATE, CREATE OR REPLACE the seed function, ON CONFLICT DO NOTHING backfill.
|
||||
-- =============================================================================
|
||||
|
||||
-- 1) New permission code seeded for new spaces (CREATE OR REPLACE the seeder).
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT NEW.id, d.subject, d.permission
|
||||
FROM (VALUES
|
||||
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
|
||||
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
|
||||
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
|
||||
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
|
||||
('board','certifications.manage'),('board','certifications.grant'),
|
||||
('board','classes.manage'),('board','classes.instruct'),
|
||||
('board','equipment.manage'),
|
||||
('board','door.manage'),('board','door.operate'),('board','apicall.invoke'),
|
||||
('board','customize.manage'),('board','settings.manage'),
|
||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||
('member','ops.kb.read'),('member','ops.process.read'),
|
||||
('associate','ops.kb.read')
|
||||
) AS d(subject, permission)
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 2) Backfill existing spaces (board gets apicall.invoke; admin holds it
|
||||
-- implicitly and is never stored).
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT id, 'board', 'apicall.invoke' FROM public.spaces
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
|
||||
-- 3) Tables.
|
||||
CREATE TABLE IF NOT EXISTS public.api_buttons (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 120),
|
||||
button_group text NOT NULL DEFAULT 'General' CHECK (char_length(button_group) BETWEEN 1 AND 60),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
method text NOT NULL DEFAULT 'POST'
|
||||
CHECK (method IN ('GET','POST','PUT','PATCH','DELETE')),
|
||||
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
|
||||
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
|
||||
url_template text,
|
||||
headers jsonb NOT NULL DEFAULT '{}',
|
||||
body_template text,
|
||||
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,
|
||||
required_permission text NOT NULL DEFAULT 'apicall.invoke'
|
||||
CHECK (char_length(required_permission) BETWEEN 1 AND 60),
|
||||
confirm boolean NOT NULL DEFAULT true,
|
||||
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_api_buttons_space ON public.api_buttons (space_id, button_group, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.api_call_log (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
button_id uuid REFERENCES public.api_buttons(id) ON DELETE SET NULL,
|
||||
actor_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_api_call_log_space ON public.api_call_log (space_id, occurred_at DESC);
|
||||
|
||||
ALTER TABLE public.api_buttons ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.api_call_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS api_buttons_select ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_insert ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_update ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_delete ON public.api_buttons;
|
||||
CREATE POLICY api_buttons_select ON public.api_buttons FOR SELECT
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_insert ON public.api_buttons FOR INSERT
|
||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_update ON public.api_buttons FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_delete ON public.api_buttons FOR DELETE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
|
||||
DROP POLICY IF EXISTS api_call_log_select ON public.api_call_log;
|
||||
CREATE POLICY api_call_log_select ON public.api_call_log FOR SELECT
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
-- No INSERT/UPDATE/DELETE policy: the validated service-client invoker is the
|
||||
-- only writer, and the audit trail is immutable.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_api_buttons_touch ON public.api_buttons;
|
||||
CREATE TRIGGER trg_api_buttons_touch
|
||||
BEFORE UPDATE ON public.api_buttons
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
+87
-1
@@ -1877,7 +1877,7 @@ BEGIN
|
||||
('board','certifications.manage'),('board','certifications.grant'),
|
||||
('board','classes.manage'),('board','classes.instruct'),
|
||||
('board','equipment.manage'),
|
||||
('board','door.manage'),('board','door.operate'),
|
||||
('board','door.manage'),('board','door.operate'),('board','apicall.invoke'),
|
||||
('board','customize.manage'),('board','settings.manage'),
|
||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||
('member','ops.kb.read'),('member','ops.process.read'),
|
||||
@@ -2551,6 +2551,11 @@ CREATE TABLE IF NOT EXISTS public.door_connections (
|
||||
verbs jsonb NOT NULL DEFAULT '{}',
|
||||
allow_member_self_entry boolean NOT NULL DEFAULT false,
|
||||
is_enabled boolean NOT NULL DEFAULT true,
|
||||
-- Inbound access-log ingest (053). inbound_enabled gates the per-connection
|
||||
-- webhook endpoint (opt-in). inbound_secret_ref is the INBOUND bearer secret
|
||||
-- in the AES vault, distinct from secret_ref (the outbound door password).
|
||||
inbound_enabled boolean NOT NULL DEFAULT false,
|
||||
inbound_secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL,
|
||||
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()
|
||||
@@ -2566,9 +2571,15 @@ CREATE TABLE IF NOT EXISTS public.door_access_log (
|
||||
action text NOT NULL,
|
||||
success boolean NOT NULL DEFAULT false,
|
||||
detail text,
|
||||
-- Per-event idempotency token for inbound ingest (053). NULL for app-issued
|
||||
-- action rows; set for ingested poll/webhook events. Partial-unique below.
|
||||
dedupe_key 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);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_door_access_log_dedupe
|
||||
ON public.door_access_log (connection_id, dedupe_key)
|
||||
WHERE dedupe_key IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY;
|
||||
@@ -2642,6 +2653,81 @@ CREATE TRIGGER trg_door_card_slots_touch
|
||||
BEFORE UPDATE ON public.door_card_slots
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Universal API-call UI builder (Door epic phase 5; migration 054).
|
||||
-- api_buttons: per-space button definitions fired through the same hardened
|
||||
-- egress as the door executor (per-button pinned_host SSRF pin; secret in
|
||||
-- the AES vault via secret_ref, never stored here). All CRUD = door.manage;
|
||||
-- each button's required_permission (default apicall.invoke) gates pressing
|
||||
-- it (enforced by the service-client invoke action, not RLS).
|
||||
-- api_call_log: append-only, secrets-redacted audit. SELECT = door.manage;
|
||||
-- NO client write policy (validated service-client invoker only; immutable).
|
||||
-- New permission code apicall.invoke (group Access) is seeded above.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.api_buttons (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 120),
|
||||
button_group text NOT NULL DEFAULT 'General' CHECK (char_length(button_group) BETWEEN 1 AND 60),
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
method text NOT NULL DEFAULT 'POST'
|
||||
CHECK (method IN ('GET','POST','PUT','PATCH','DELETE')),
|
||||
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
|
||||
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
|
||||
url_template text,
|
||||
headers jsonb NOT NULL DEFAULT '{}',
|
||||
body_template text,
|
||||
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,
|
||||
required_permission text NOT NULL DEFAULT 'apicall.invoke'
|
||||
CHECK (char_length(required_permission) BETWEEN 1 AND 60),
|
||||
confirm boolean NOT NULL DEFAULT true,
|
||||
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_api_buttons_space ON public.api_buttons (space_id, button_group, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.api_call_log (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
button_id uuid REFERENCES public.api_buttons(id) ON DELETE SET NULL,
|
||||
actor_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_api_call_log_space ON public.api_call_log (space_id, occurred_at DESC);
|
||||
|
||||
ALTER TABLE public.api_buttons ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.api_call_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS api_buttons_select ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_insert ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_update ON public.api_buttons;
|
||||
DROP POLICY IF EXISTS api_buttons_delete ON public.api_buttons;
|
||||
CREATE POLICY api_buttons_select ON public.api_buttons FOR SELECT
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_insert ON public.api_buttons FOR INSERT
|
||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_update ON public.api_buttons FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
CREATE POLICY api_buttons_delete ON public.api_buttons FOR DELETE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
|
||||
DROP POLICY IF EXISTS api_call_log_select ON public.api_call_log;
|
||||
CREATE POLICY api_call_log_select ON public.api_call_log FOR SELECT
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_api_buttons_touch ON public.api_buttons;
|
||||
CREATE TRIGGER trg_api_buttons_touch
|
||||
BEFORE UPDATE ON public.api_buttons
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 24. Presence & attendance (check-in / check-out / hosting)
|
||||
|
||||
Reference in New Issue
Block a user