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:
Moheeb Zara
2026-05-20 20:45:44 -07:00
parent 6a892cbad5
commit 7ec90b0f1e
33 changed files with 2277 additions and 95 deletions
+127
View File
@@ -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();