-
Notifications
You must be signed in to change notification settings - Fork 0
feat(procore): Track A batch 2 — audit retention, JSONL gate, SBOM/vuln scan (T5,T6,T9) #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27194fa
2954468
10f76aa
37f6874
91d5ec4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,4 @@ pytest-xdist==3.8.0 | |
| pytest-asyncio | ||
| flake8 | ||
| pre-commit | ||
| pip-audit[cyclonedx] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| -- 008_procore_audit.sql | ||
| -- Minimal, append-only audit trail for Procore SWMS reviews (T6). | ||
| -- | ||
| -- Stores ONLY audit metadata (hashes, rule-pack versions, finding counts, | ||
| -- status, write-back metadata, human-override trail) — never raw SWMS text or | ||
| -- Procore document content. Supports the "Procore remains system of record / | ||
| -- minimal audit metadata retained" data-handling statement. | ||
| -- | ||
| -- Hardening (per docs/procore/STAGE3_CERTIFICATION_PLAN_V2.md §5 + pre-flight): | ||
| -- * table lives in a PRIVATE (non-PostgREST-exposed) schema with RLS; | ||
| -- * append-only: UPDATE always blocked; DELETE only via the controlled | ||
| -- retention/deletion functions (SECURITY DEFINER, run as owner); | ||
| -- * writes/purges/deletions go through SECURITY DEFINER functions in public | ||
| -- whose EXECUTE is granted to service_role only. | ||
|
|
||
| create schema if not exists private; | ||
|
|
||
| create table if not exists private.procore_audit ( | ||
| id bigint generated always as identity primary key, | ||
| record_type text not null default 'review', -- 'review' | 'override' | ||
| review_run_id text, | ||
| delivery_key text, | ||
| correlation_id text, | ||
| company_id bigint, | ||
| project_id bigint, | ||
| document_hash text, -- sha256 of the document; never raw SWMS text | ||
| rule_pack_version text, | ||
| rule_library_version text, | ||
| project_review_status text, | ||
| status_recommendation text, | ||
| workflow_state text, | ||
| review_confidence text, | ||
| finding_count integer, | ||
| hard_fail_count integer, | ||
| writeback jsonb not null default '{}'::jsonb, | ||
| reviewer_override jsonb, -- present on record_type = 'override' | ||
| retention_days integer not null default 365, | ||
| created_at timestamptz not null default now() | ||
| ); | ||
|
|
||
| alter table private.procore_audit enable row level security; | ||
| -- No policies on purpose: only the SECURITY DEFINER functions (owner) and | ||
| -- service_role (which bypasses RLS) may touch this table. | ||
|
|
||
| create index if not exists idx_procore_audit_company_created | ||
| on private.procore_audit (company_id, created_at desc); | ||
| create index if not exists idx_procore_audit_created | ||
| on private.procore_audit (created_at); | ||
|
|
||
| -- Append-only: block UPDATE always; block DELETE for non-admin/owner roles. | ||
| -- The retention/deletion functions below are SECURITY DEFINER and run as the | ||
| -- function owner, so they pass the DELETE guard while ad-hoc deletes do not. | ||
| create or replace function private.prevent_procore_audit_mutation() | ||
| returns trigger | ||
| language plpgsql | ||
| security definer | ||
| as $$ | ||
| begin | ||
| if tg_op = 'UPDATE' then | ||
| raise exception 'private.procore_audit is append-only; UPDATE not permitted.'; | ||
| end if; | ||
| if current_user not in ('audit_admin', 'postgres') then | ||
| raise exception 'private.procore_audit is append-only; DELETE not permitted.'; | ||
| end if; | ||
| return old; | ||
| end; | ||
| $$; | ||
|
|
||
| drop trigger if exists no_update_procore_audit on private.procore_audit; | ||
| create trigger no_update_procore_audit | ||
| before update on private.procore_audit | ||
| for each row execute function private.prevent_procore_audit_mutation(); | ||
|
|
||
| drop trigger if exists no_delete_procore_audit on private.procore_audit; | ||
| create trigger no_delete_procore_audit | ||
| before delete on private.procore_audit | ||
| for each row execute function private.prevent_procore_audit_mutation(); | ||
|
|
||
| -- Write one audit record (review or override). SECURITY DEFINER so PostgREST | ||
| -- callers reach the private table; EXECUTE restricted to service_role. | ||
| create or replace function public.record_procore_audit(p_record jsonb) | ||
| returns bigint | ||
| language plpgsql | ||
| security definer | ||
|
Comment on lines
+81
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The certification plan's Supabase RPC hardening section says T6 must not create Useful? React with 👍 / 👎. |
||
| set search_path = private, public | ||
| as $$ | ||
| declare | ||
| v_id bigint; | ||
| begin | ||
| insert into private.procore_audit ( | ||
| record_type, review_run_id, delivery_key, correlation_id, | ||
| company_id, project_id, document_hash, rule_pack_version, | ||
| rule_library_version, project_review_status, status_recommendation, | ||
| workflow_state, review_confidence, finding_count, hard_fail_count, | ||
| writeback, reviewer_override, retention_days | ||
| ) | ||
| values ( | ||
| coalesce(p_record->>'record_type', 'review'), | ||
| p_record->>'review_run_id', | ||
| p_record->>'delivery_key', | ||
| p_record->>'correlation_id', | ||
| (p_record->>'company_id')::bigint, | ||
| (p_record->>'project_id')::bigint, | ||
| p_record->>'document_hash', | ||
| p_record->>'rule_pack_version', | ||
| p_record->>'rule_library_version', | ||
| p_record->>'project_review_status', | ||
| p_record->>'status_recommendation', | ||
| p_record->>'workflow_state', | ||
| p_record->>'review_confidence', | ||
| (p_record->>'finding_count')::integer, | ||
| (p_record->>'hard_fail_count')::integer, | ||
| coalesce(p_record->'writeback', '{}'::jsonb), | ||
| p_record->'reviewer_override', | ||
| coalesce((p_record->>'retention_days')::integer, 365) | ||
| ) | ||
| returning id into v_id; | ||
| return v_id; | ||
| end; | ||
| $$; | ||
|
|
||
| -- Retention purge: delete rows past their per-row retention window. | ||
| create or replace function public.purge_procore_audit() | ||
| returns integer | ||
| language plpgsql | ||
| security definer | ||
| set search_path = private, public | ||
| as $$ | ||
| declare | ||
| v_deleted integer; | ||
| begin | ||
| delete from private.procore_audit | ||
| where created_at < now() - (retention_days || ' days')::interval; | ||
| get diagnostics v_deleted = row_count; | ||
| return v_deleted; | ||
| end; | ||
| $$; | ||
|
|
||
| -- Customer deletion request: remove all audit rows for a company. | ||
| create or replace function public.delete_procore_audit_for_company(p_company_id bigint) | ||
| returns integer | ||
| language plpgsql | ||
| security definer | ||
| set search_path = private, public | ||
| as $$ | ||
| declare | ||
| v_deleted integer; | ||
| begin | ||
| delete from private.procore_audit where company_id = p_company_id; | ||
| get diagnostics v_deleted = row_count; | ||
| return v_deleted; | ||
| end; | ||
| $$; | ||
|
|
||
| -- Least privilege: only the service role may call these RPCs. | ||
| revoke all on function public.record_procore_audit(jsonb) from public, anon, authenticated; | ||
| revoke all on function public.purge_procore_audit() from public, anon, authenticated; | ||
| revoke all on function public.delete_procore_audit_for_company(bigint) from public, anon, authenticated; | ||
| grant execute on function public.record_procore_audit(jsonb) to service_role; | ||
| grant execute on function public.purge_procore_audit() to service_role; | ||
| grant execute on function public.delete_procore_audit_for_company(bigint) to service_role; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| """Contract test for migration 008 — Procore audit trail (T6). | ||
|
|
||
| The migration cannot be applied in CI, so this guards the hardening invariants | ||
| against silent weakening: private schema + RLS, append-only triggers, retention | ||
| and customer-deletion functions, service-role-only EXECUTE, and no raw text. | ||
| """ | ||
| from pathlib import Path | ||
|
|
||
| MIGRATION = ( | ||
| Path(__file__).resolve().parent.parent | ||
| / "supabase" / "migrations" / "008_procore_audit.sql" | ||
| ) | ||
|
|
||
|
|
||
| def _sql() -> str: | ||
| return MIGRATION.read_text(encoding="utf-8").lower() | ||
|
|
||
|
|
||
| def test_migration_file_exists(): | ||
| assert MIGRATION.exists() | ||
|
|
||
|
|
||
| def test_table_is_private_with_rls(): | ||
| sql = _sql() | ||
| assert "create table if not exists private.procore_audit" in sql | ||
| assert "alter table private.procore_audit enable row level security" in sql | ||
|
|
||
|
|
||
| def test_append_only_triggers(): | ||
| sql = _sql() | ||
| assert "before update on private.procore_audit" in sql | ||
| assert "before delete on private.procore_audit" in sql | ||
| assert "append-only" in sql | ||
|
|
||
|
|
||
| def test_retention_and_deletion_functions(): | ||
| sql = _sql() | ||
| assert "function public.purge_procore_audit" in sql | ||
| assert "function public.delete_procore_audit_for_company" in sql | ||
| assert "retention_days" in sql | ||
|
|
||
|
|
||
| def test_service_role_only_execute(): | ||
| sql = _sql() | ||
| for fn in ( | ||
| "public.record_procore_audit(jsonb)", | ||
| "public.purge_procore_audit()", | ||
| "public.delete_procore_audit_for_company(bigint)", | ||
| ): | ||
| assert f"grant execute on function {fn} to service_role" in sql | ||
| assert f"revoke all on function {fn} from public, anon, authenticated" in sql | ||
|
|
||
|
|
||
| def test_stores_hash_not_raw_text(): | ||
| sql = _sql() | ||
| assert "document_hash" in sql | ||
| assert "never raw swms text" in sql |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the default environment this early return prevents
store_artifact()from persisting the full review artifact, but_process_procore_v1_webhookstill relies on this same store for resubmission comparison viafind_previous_artifact()(api/main.py:1412-1429). Since the new Supabase audit migration stores only metadata and there is no Supabase reader/writer for comparison artifacts, normal deployments withoutPROCORE_LOCAL_JSONL_ENABLED=truewill never retain reviews for later resubmission comparisons.Useful? React with 👍 / 👎.