Skip to content

Forms Portal — Compliance Controls Mapping

Classification: CONFIDENTIAL — Internal Use Only

Document version

v1.0 — 2026-04-18 — derived from forms-backend/compliance/cis-pci-nist-mitre.md at Phase 1 cut.

Scope — inventory IDs gpus_forms_frontend (the user-facing SPA) and gpus_forms_backend (the API), plus gpus_forms_db.

This document is the authoritative mapping of the Forms Portal (forms.greenpeace.us) implementation against CIS Controls v8, PCI-DSS v4.0, NIST 800-53 Rev 5, and MITRE ATT&CK mitigations. It is reviewed quarterly by the Director of Cyber Security and requires owner sign-off before production cutover (tracked in Cowork Task 6 of the Phase 1 runbook).

The operational/architecture description of the portal lives at Forms Portal — forms.greenpeace.us. This page covers controls and evidence only.


Data classification

Data element Classification Handling
Submission field values (default) CONFIDENTIAL AES-256-GCM envelope encryption, per-submission DEK wrapped by KMS
Searchable field mirrors (searchable: true) INTERNAL Plaintext JSONB column, indexed for admin search
Attachments CONFIDENTIAL GCS with CMEK, signed URLs, ClamAV scanned, 7-year retention lock
Submitter identity (email, username) INTERNAL Plaintext columns (operational necessity for audit)
Audit log CONFIDENTIAL Append-only (REVOKE UPDATE/DELETE), 7-year retention
DEK wrapping key RESTRICTED Cloud KMS, CMEK, software-protected, automatic 90-day rotation

Encryption — data in transit

Channel Protocol Enforcement
Browser → Cloud Run TLS 1.3, HSTS 2yr + preload Cloud Run managed cert; HSTS header set in app.py
Cloud Run → Cloud SQL TLS 1.3 + mTLS via Cloud SQL Connector IAM auth; enable_iam_auth=True in db.py
Cloud Run → KMS / GCS / Secret Manager TLS 1.3 (Google private endpoints) Service account OAuth2 via ADC
Cloud Run → HappyFox HTTPS (TLS 1.2+) Validated in requests call
Cloud Run → VPC (private IP) TLS via Cloud SQL Connector vpc-egress=private-ranges-only

Encryption — data at rest

Asset Cipher Key
Cloud SQL storage AES-256 (Google-managed layer) + CMEK (customer-managed) gpus-forms-cmek
Cloud SQL automated backups Same as instance Inherited
Submission field values AES-256-GCM Per-submission DEK, wrapped by KMS key gpus-forms-dek-wrapper
GCS attachments bucket AES-256 + CMEK gpus-forms-cmek
GCS backup bucket AES-256 + CMEK gpus-forms-cmek
Secret Manager AES-256 (Google-managed) Managed

CIS Controls v8

Control Title Implementation
3.11 Encrypt Sensitive Data at Rest CMEK on Cloud SQL + GCS; AES-256-GCM envelope on submission fields
3.12 Segment Data Processing Dedicated service account gpus-forms-backend@; private IP; VPC connector
4.4 Firewall Cloud SQL private IP only; Cloud Armor WAF at edge; Cloud Run no-allow-unauthenticated
5.1 Account Management Okta SSO via OIDC; users table mirrors Okta identity; roles admin/viewer only
6.3 Multi-factor Authentication MFA enforced by Okta policy at tenant level
6.8 Privileged Account Access Admin role granted in users table; audited via user_granted/user_revoked events
8.2 Audit Log Collection Append-only audit_log table; structured JSON to Cloud Logging → CEDAR
8.3 Log Storage 7-year retention; Cloud Logging sink to GCS with bucket lock
8.5 Centralized Log Management Cloud Logging → CEDAR Elasticsearch (existing pipeline)
8.11 Audit Log Review Wazuh rules 100020–100025 on MAPLE; SOC dashboard Tickets tab
10.6 Vulnerability Scanning Cloud Build step runs bandit + pip-audit; OAK OpenVAS weekly; Artifact Registry vuln scanning
13.1 Threat Detection Wazuh rules trigger SOC auto-ticketing at level ≥ 10
16.1 Secure Application Development Pydantic input validation; parameterized SQL via SQLAlchemy; CSRF tokens; security headers

PCI-DSS v4.0

Forms portal does not process cardholder data. PCI-DSS mapping documented for defense-in-depth alignment only.

Req Implementation
1.2.1 Inbound traffic restricted — Cloud Run auth required, Cloud Armor WAF filters
2.2.1 Secure configuration — CIS-hardened base image, distroless python, non-root container user
3.5 Key management — KMS-wrapped DEK; rule 100024 fires on decrypt failure
3.6 Key rotation — KMS automatic 90-day rotation; re-encrypt job runs on key version change
4.1 Strong cryptography for transmission — TLS 1.3, HSTS preload
6.5.9 Protect against CSRF — Flask-WTF CSRF tokens; rule 100022 logs rejects
7.1 Access to data limited to need — forms_viewer RLS role; admin actions audited
8.3.6 Password complexity — N/A (Okta SSO, no passwords in forms portal)
10.2.1–10.2.7 Audit trails — audit_log captures auth, admin, decrypt, export events
10.3 Audit trail protection — REVOKE UPDATE/DELETE on audit_log; central forwarding to CEDAR
10.7 Log retention — 7 years
11.3 Vulnerability scans — OpenVAS weekly (OAK); image scan on every build

NIST 800-53 Rev 5

Control Implementation
AC-2 Account Management — Okta + users table sync
AC-3 Access Enforcement — @require_role decorators; Postgres RLS
AC-6 Least Privilege — 2 roles only; service account scoped to Cloud SQL client + KMS encrypter/decrypter + GCS object creator/viewer
AU-2 Event Logging — audit_log table + Cloud Logging
AU-6 Audit Record Review — Wazuh rules + SOC dashboard
AU-9 Protection of Audit Information — append-only, REVOKE UPDATE/DELETE
AU-11 Audit Record Retention — 7 years
CM-7 Least Functionality — minimal container, no shell, distroless-style
IA-2 Identification & Authentication — Okta OIDC, JWT verification
IA-5 Authenticator Management — handled by Okta
SC-8 Transmission Confidentiality — TLS 1.3 everywhere
SC-12 Cryptographic Key Establishment — KMS-managed CMEK + DEK
SC-13 Cryptographic Protection — AES-256-GCM, FIPS-validated via BoringCrypto in GCP
SC-28 Protection of Info at Rest — envelope encryption + CMEK
SI-4 System Monitoring — Prometheus + Wazuh
SI-10 Information Input Validation — pydantic

MITRE ATT&CK mitigations

Technique Mitigation
T1078 (Valid Accounts) Okta MFA; role-based access; account lifecycle via Okta lifecycle management
T1190 (Exploit Public-Facing Application) Cloud Armor OWASP rules; pydantic input validation; bandit + pip-audit on build
T1213 (Data from Information Repositories) RLS on submissions; decrypt events audited; bulk export rule 100025
T1537 (Transfer Data to Cloud Account) Egress restricted to private ranges; signed URLs for attachments; bulk export alerts
T1552 (Unsecured Credentials) All secrets in Secret Manager; no passwords in code or env vars; IAM auth for DB
T1562 (Impair Defenses) Audit log append-only at DB role level; separate forms_readonly role for reports

Control ownership

Area Owner Review cadence
Cryptographic controls Director of Cyber Security Quarterly
Access controls (Okta + RLS) Director of Cyber Security Quarterly
Audit log review SOC Weekly (via SOC dashboard)
Vulnerability scanning SOC (OAK) Weekly
Key rotation verification Director of Cyber Security Quarterly
Retention policy enforcement Director of Cyber Security Monthly (purge job audit)

Sign-off before production

  • [ ] Director of Cyber Security reviews this document
  • [ ] Wazuh rules deployed and verified firing on test events
  • [ ] Prometheus alerts routed to correct email/Slack channels
  • [ ] OAK OpenVAS target added, baseline scan complete with zero criticals
  • [ ] Penetration test scope includes forms portal (update pentest-schedule.md)
  • [ ] Disaster recovery test completed (restore from GCS backup, verify decrypt works)
  • [ ] IRP (irp.md) updated with forms-portal-specific playbook entries

Rotation verification log

Quarterly execution log for Cowork Task 8 (forms-portal credential rotation check) from forms-backend/RUNBOOK-COWORK.md. Each run records one line per check with status PASS / FLAG / ACTION-NEEDED and any operator notes. This skill never rotates secrets automatically — it verifies state and raises follow-ups.

2026-04-20 — Q2 2026 check (automated run)

Executed by: Cowork scheduled task forms-portal-credential-rotation-quarterly (agent, unattended — Rajesh not present for interactive walk-through).

Verification evidence available to the agent was limited to the repo — no gcloud CLI, no GCP Console session, and no HappyFox support channel were reachable from the scheduled-task sandbox. Items that require live console state are therefore logged as ACTION-NEEDED for Rajesh to complete manually and back-fill into this log at the next available window.

# Check Status Notes
1 HappyFox credentials rotation (gpus-forms-happyfox-api-key, gpus-forms-happyfox-auth-code) ACTION-NEEDED Requires an outbound ticket/email to HappyFox support (not executable by agent). If HappyFox confirms rotation, procedure is: add new version to Secret Manager, verify /health + one test submission, disable (not delete) previous version.
2 Okta client secret rotation policy PASS (with FLAG for MFA) config.py references OKTA_CLIENT_ID only (SPA); no OKTA_CLIENT_SECRET env var or secret exists. SPA pattern has no secret to rotate (RUNBOOK-COWORK.md Task 4, Option A confirms reuse of existing "Greenpeace.US" SPA app, client id 0oadhpjktd5UfCMDm0x7). FLAG: MFA enforcement on the Okta sign-on policy needs manual confirmation in Okta Admin each quarter — not verifiable from the repo.
3 KMS key rotation timestamps (gpus-forms-cmek, gpus-forms-dek-wrapper) ACTION-NEEDED Keys were created with --rotation-period=90d per Step 3 of RUNBOOK-CLAUDE-CODE.md, so automatic rotation is configured. Per-version rotation timestamps (must be within the last 100 days) require GCP Console → Security → KMS → keyring gpus-forms (us-central1) and are not visible to the agent.
4 Cloud SQL backups (gpus-forms-db: 7 retained, PITR enabled, latest <24h) ACTION-NEEDED Requires GCP Console → Cloud SQL → gpus-forms-db → Backups tab. Not verifiable from the repo.

Overall result: 1 PASS (with secondary FLAG), 3 ACTION-NEEDED.

Follow-up: A 7-day Cowork follow-up has been scheduled (forms-portal-rotation-followup-2026-q2) to walk Rajesh through items 1, 3, and 4 live and back-fill verified statuses into this log. The MFA-enforcement FLAG on item 2 should be closed out during the same session.

2026-07-20 — Q3 2026 check (automated run)

Executed by: Cowork scheduled task forms-portal-credential-rotation-quarterly (agent, unattended — Rajesh not present for the interactive walk-through this check is designed around).

Same evidence boundary as the Q2 run: verification was limited to repo state. No gcloud/gsutil CLI, no authenticated GCP Console session, and no HappyFox support channel were reachable from the scheduled-task sandbox (the sandbox is isolated and carries none of the operator's cloud credentials). Items requiring live console state are therefore logged ACTION-NEEDED for Rajesh to complete manually and back-fill here at the next window. Repo-checkable items were re-verified this quarter rather than carried forward.

# Check Status Notes
1 HappyFox credentials rotation (gpus-forms-happyfox-api-key, gpus-forms-happyfox-auth-code) ACTION-NEEDED Requires an outbound ticket/email to HappyFox support — not an action the unattended agent takes (and the task explicitly forbids automatic rotation). If HappyFox confirms rotation: add a new version to each secret in Secret Manager, verify /health + one test submission, then disable (not delete) the prior version. Caveat re-confirmed this quarter: RUNBOOK-CLAUDE-CODE.md (line 336) records the vendor position as "we reuse current values (vendor won't rotate)" — if that still holds, this check's practical outcome is "vendor rotation not available; compensating controls (Secret Manager access scoped to gpus-forms-backend@, no secret in code/env) remain in force." Rajesh should confirm the current vendor stance and record it.
2 Okta client secret rotation policy PASS (with FLAG for MFA) Re-verified in forms-backend/config.py: only OKTA_CLIENT_ID (0oadhpjktd5UfCMDm0x7, default) and OKTA_DOMAIN/OKTA_ISSUER/OKTA_AUDIENCE are referenced — no OKTA_CLIENT_SECRET env var or Secret Manager entry exists anywhere in the backend. The forms app is a public SPA (RUNBOOK-COWORK.md Task 4, Option A — reuse of the existing "Greenpeace.US" SPA app), so there is no confidential-client secret to rotate. FLAG (unchanged from Q2): MFA enforcement on the Okta sign-on policy must be confirmed manually in Okta Admin each quarter; it is not verifiable from the repo.
3 KMS key rotation timestamps (gpus-forms-cmek, gpus-forms-dek-wrapper) ACTION-NEEDED Both keys were created with --rotation-period=90d (Step 3, RUNBOOK-CLAUDE-CODE.md), so automatic rotation is configured in code/runbook. Actual per-version rotation timestamps (must be within the last 100 days) are only visible in GCP Console → Security → KMS → keyring gpus-forms (us-central1) and cannot be read by the agent. Confirm the primary version's create time on each key is < 100 days old.
4 Cloud SQL backups (gpus-forms-db: 7 retained, PITR enabled, latest <24h) ACTION-NEEDED Requires GCP Console → Cloud SQL → gpus-forms-db → Backups tab. Not verifiable from the repo. Confirm: exactly 7 automated backups retained, point-in-time recovery enabled, and the most recent backup completed within the last 24 hours.

Overall result: 1 PASS (with a secondary MFA FLAG), 3 ACTION-NEEDED. No status regressed versus Q2 2026; the ACTION-NEEDED items are unattended-context limitations, not detected control failures.

Follow-up: A 7-day Cowork follow-up has been scheduled (forms-portal-rotation-followup-2026-q3) to walk Rajesh through items 1, 3, and 4 live and back-fill verified statuses into this log. The MFA-enforcement FLAG on item 2 should be closed out in the same session. Note whether the Q2 follow-up (forms-portal-rotation-followup-2026-q2) was completed — if those items were never back-filled, this is the second quarter running without live console verification and should be prioritized.


Retention audit log

Monthly execution log for the "Retention policy enforcement" control (control ownership table above, Director of Cyber Security, monthly cadence). Each entry records the previous calendar month's retention purge-job state with status PASS / FLAG / ACTION-NEEDED / INCIDENT. This skill never deletes data — it verifies the purge job ran, that counts are plausible, that a sampled purged row is actually gone, and that the GCS bucket retention lock is intact.

2026-06-01 — May 2026 audit (automated run)

Executed by: Cowork scheduled task forms-portal-purge-job-audit-monthly (agent, unattended — Rajesh not present for interactive walk-through).

Verification evidence available to the agent was limited to the repo. The agent had no gcloud CLI, no GCP Console session, no Cloud SQL psql/IAM-auth session, and no GCS console access from the scheduled-task sandbox. Items that require live cloud state are therefore logged as ACTION-NEEDED for Rajesh to complete manually and back-fill into this log at the next available window. The repo state was checked to confirm the purge-job code path still exists and the schema still defines the relevant fields and audit-action enum — both PASS.

# Check Status Notes
1 Purge job ran in May 2026 ACTION-NEEDED Requires Cloud Logging filter resource.type="cloud_run_revision" resource.labels.service_name="gpus-forms-backend" labels.job="retention-purge" with time range 2026-05-01T00:00:00Z..2026-06-01T00:00:00Z. Confirm ≥1 successful run. If zero runs found → escalate as INCIDENT and page on-call. Code path (admin_purge_expired audit action, retention_expires_at < NOW() predicate) is still present in forms-backend/models.py and forms-backend/schema/001_init.sql — PASS at the code-existence layer.
2 Reported counts plausible (submissions / audit rows / attachments / DEK wrappers) ACTION-NEEDED Pull structured log fields submissions_purged, audit_rows_purged, attachments_purged, dek_wrappers_retired from the May run(s). Phase-1 cutover was 2026-04-18, so the 7-year retention window (2555 days, see forms table retention_days default in schema/001_init.sql) cannot yet have elapsed for any production submission — the expected May 2026 count is zero for all four metrics. A non-zero count this month is itself a FLAG and warrants investigation (test/staging data leaked into the prod purge path, or retention_expires_at mis-stamped on a form with retention_days < 44).
3 Spot check one purged submission N/A (this month) Conditional on Check 2 returning a non-zero submission count. With the expected zero-purge result for May 2026, there is no purged submission ID to spot-check. Procedure to retain for future months: pick one purged submission_id from log, verify it is absent from submissions, submission_fields, and gpus-forms-attachments, and verify audit_log entries for that submission (submission_created, any submission_viewed, admin_purge_expired) are still present (audit log retention is independent of submission retention — see schema header comments and AU-11 control mapping).
4 GCS bucket retention lock on gpus-forms-attachments still enabled and unaltered ACTION-NEEDED Requires GCP Console → Cloud Storage → gpus-forms-attachments → Retention tab. Confirm retention policy is locked (lock icon present, not just configured) and the retention period matches the documented 7-year window. Any alteration → CRITICAL FINDING, page Rajesh immediately per task instructions.

Overall result: 0 PASS, 3 ACTION-NEEDED, 1 N/A. No INCIDENT or CRITICAL state was reachable from the sandbox — but absence of evidence is not evidence of absence, and items 1 and 4 must be closed out by Rajesh before this month's audit can be marked complete.

Expected outcome reminder: For the first ~7 years post-Phase-1 cutover (2026-04-18 → ~2033-04), monthly purge runs should report zero purged submissions/attachments and zero retired DEK wrappers in steady state. The job must still execute monthly (Check 1) and the GCS retention lock must remain intact (Check 4) — those are the two non-trivial checks during the pre-aging period. The job firing with zero rows touched is the expected PASS shape; a missing run is INCIDENT.

Follow-up: A 7-day Cowork follow-up has been scheduled (forms-portal-purge-audit-followup-2026-05) to walk Rajesh through items 1, 2, and 4 live and back-fill verified statuses into this log.

2026-06-15 — Retention value reconciliation + attachment-bucket cleanup (Gate 5)

Performed live (operator-attended) during forms 2.5(c) Gate 5.

  • Retention value corrected 2563 → 2555 days. The bucket policy had been set to 2563 days — an error with no defensible derivation (7 years = 2555 days exactly; even counting the 2 leap days in 2026–2033 yields 2557). All other sources (DB retention_days default, every forms/*.yaml, this controls doc, the IAR) say 7 years / 2555. The GCS policy was re-set to 2555 days, closing the number mismatch.
  • Noncurrent-version cleanup. Object versioning was found Enabled, so prior "deletions" had only archived objects to noncurrent versions that the retention policy preserved. Three retained-but-invisible liabilities were removed (verified with gsutil ls -a, all-versions — not live-only): the credentials-bearing pentest PDF (Exploiting Vulnerable Hosts (4e)…pdf, never actually deleted), an EICAR test-virus file, and a superseded screenshot version. Versioning was set to Suspended to stop new noncurrent versions accruing.
  • Current policy state: retention set to 2555 days, UNLOCKED. Bucket holds 7 live test artifacts, 0 noncurrent versions.

Check 4 status — UPDATED: moves from ACTION-NEEDED (failing) to "policy correct (2555d), lock scheduled." The retention lock is deliberately deferred, not silently closed. Locking is gated on three conditions, all open:

  • (a) Bucket confirmed empty of the remaining 7 test artifacts. (Sequencing note: with 2555 now set, those objects are retention-protected; emptying them requires retention clear → rm → re-set 2555 before the lock.)
  • (b) Versioning disable-vs-suspend decision + a noncurrent-version-expiration lifecycle rule resolved. Currently Suspended; the lifecycle config has only storage-class transitions, no noncurrent expiry — tracked as a T5/controls item.
  • (c) A deliberate lock call by the Director of Cyber Security. The lock is irreversible (cannot be shortened or removed once locked), so it is not taken on autopilot.

Until locked, Check 4's monthly verification asserts: policy present at 2555 days, UNLOCKED, and the three lock-preconditions still tracked. A reduction below 2555 or an unexplained clear is a FLAG.

2026-07-18 — Retention-lock review (Task B.1, automated run)

Executed by: Cowork scheduled task forms-portal-attachments-retention-lock-review (agent, unattended — Rajesh not present for the interactive walk-through this review requires). No gcloud/GCS console session was reachable from the sandbox; findings are from repo state plus the 2026-06-15 Gate 5 entry above. Decision: DO NOT LOCK. The lock was not invoked and cannot be from an unattended session. The lock is irreversible, and three of the preconditions below require a human decision.

Gating check (step 1) — NOT cleanly met.

  • Phase 2 has cut over — frontend cutover complete 2026-04-27 (priorities/gpus-it-priorities.md change log v1.3); real submission persistence began 2026-05-08 (Phase 2.5(b), was "theater" before that).
  • The 90-day production-operation bar is not met under the strict reading: 82 days since the 2026-04-27 frontend cutover, 71 days since real persistence (2026-05-08). Only the due-date driver — 90 days after the 2026-04-18 unlock decision — has elapsed (91 days). Flagged as a discrepancy between the due-date driver and the step-1 "≥90 days in production" gate; resolves in favour of hold given irreversibility.

Lock preconditions (from 2026-06-15 Gate 5) — still open. No evidence any of the three has closed since 2026-06-15: (a) bucket still not confirmed empty of the 7 live test artifacts — locking now would make test data (previously incl. an EICAR file and a credentials-bearing pentest PDF) immutable for 7 years; (b) versioning disable-vs-suspend + noncurrent-expiry lifecycle rule unresolved; (c) deliberate lock call by the Director of Cyber Security not yet made.

Category retention review (step 2). All 28 active forms/*.yaml declare retention_days: 2555 (7 years) uniformly. Categories present: hr, finance, supporter-care, it, facilities, other (the Data Team forms — data-analysis-request, data-request-form, database-* — sit under other). Assessment of whether an immutable 7-year floor fits each — all items below need Rajesh's confirmation, none is a confirmed exception yet:

  • Human Resources — 7 years is a reasonable floor; some records (pension/benefits, employment history) may warrant longer. A floor does not force deletion at 7 years, so "keep longer" is not a conflict. Likely OK.
  • Finance — 7 years aligns with IRS/tax record retention. Likely correct.
  • Supporter Care (frontline-form-request, refund-processing-form) — primary concern. An immutable 7-year floor forbids early deletion, which conflicts with data-minimization and right-to-erasure obligations (CCPA / state privacy laws / GDPR for any EU supporters) for donor PII. Refund/financial records may need 7 years, but non-financial supporter PII may need to be deletable on request. Needs a decision before an irreversible floor is applied.
  • Data Team / other operational tickets (IT, facilities, data requests) — a 7-year immutable floor likely over-retains short-lived operational attachments, enlarging breach-exposure surface. Data-minimization concern, not a legal conflict.

Recommendation. Do not lock until: (1) the three Gate-5 preconditions close (bucket emptied of test artifacts, versioning/lifecycle resolved, deliberate lock call); (2) the 90-day production-operation bar is met (~2026-07-26 by frontend cutover, ~2026-08-06 by real persistence); and (3) Rajesh confirms Supporter Care / operational-ticket retention — if either needs sub-7-year deletion, the single-bucket immutable-lock model is the wrong shape and the choice is split-buckets-per-category vs. application-level lifecycle within one bucket (the per-form retention_days metadata already supports the latter). If confirmed to lock after all preconditions clear, the exact interactive command is gsutil retention lock gs://gpus-forms-attachmentsirreversible; run only with explicit understanding that no object can be deleted before 7 years, even by an owner.

Check 4 status — unchanged: policy present at 2555 days, UNLOCKED, three preconditions still tracked (plus the new Supporter Care erasure-rights question). This review scheduled a 60-day follow-up.

2026-08-01 — July 2026 audit (automated run)

Executed by: Cowork scheduled task forms-portal-purge-job-audit-monthly (agent, unattended — Rajesh not present for the interactive walk-through this audit is designed around).

Same evidence boundary as the June (May-audit) run: verification was limited to repo state. No gcloud/gsutil CLI, no authenticated GCP Console session, no Cloud SQL psql/IAM-auth session, and no GCS console access were reachable from the scheduled-task sandbox (confirmed this run — gcloud, gsutil, psql all absent; no cloud credentials in the environment). Items requiring live cloud state are logged ACTION-NEEDED for Rajesh to close manually and back-fill here. Repo-checkable items were re-verified this month.

# Check Status Notes
1 Purge job ran in July 2026 ACTION-NEEDED (live) / PASS (code layer) Live confirmation requires Cloud Logging filter resource.type="cloud_run_revision" resource.labels.service_name="gpus-forms-backend" labels.job="retention-purge", time range 2026-07-01T00:00:00Z..2026-08-01T00:00:00Z. Confirm ≥1 successful run. If zero runs found → escalate as INCIDENT and page on-call. Code layer re-verified PASS this run: admin_purge_expired audit action present in forms-backend/models.py (AUDIT_ACTIONS tuple, noted verified == live 31-value DB enum on 2026-07-28) and schema/001_init.sql; retention_expires_at column + idx_submissions_retention partial index (WHERE status != 'purged') present; purged submission status present. Purge code path is intact.
2 Reported counts plausible (submissions / audit rows / attachments / DEK wrappers) ACTION-NEEDED (live) — expected zero Pull structured log fields submissions_purged, audit_rows_purged, attachments_purged, dek_wrappers_retired from the July run(s). Expected July 2026 count is zero for all four metrics. Earliest real production persistence was 2026-05-08 (Phase 2.5(b)); the 7-year window (2555 days — re-confirmed uniform across all 29 active forms/*.yaml, the forms.retention_days DB default, and retention_positive CHECK) cannot elapse before ~2033. Any non-zero count this month is itself a FLAG warranting investigation (test/staging data in the prod purge path, or a mis-stamped retention_expires_at on a form with retention_days < ~85).
3 Spot check one purged submission N/A (this month) Conditional on Check 2 returning a non-zero submission count. With the expected zero-purge result for July 2026, there is no purged submission_id to spot-check. Procedure retained for future months: pick one purged submission_id from the log, verify absence from submissions, submission_fields, and gpus-forms-attachments, and verify its audit_log entries (submission_created, any submission_viewed, admin_purge_expired) are STILL present (audit-log retention is independent of submission retention — AU-11).
4 GCS bucket retention policy on gpus-forms-attachments intact ACTION-NEEDED (live) Requires GCP Console → Cloud Storage → gpus-forms-attachments → Retention tab. Expected state is UNLOCKED at 2555 days per the 2026-07-18 retention-lock review (DO NOT LOCK decision — three Gate-5 preconditions plus the Supporter Care erasure-rights question still open). This month asserts: policy present at 2555 days, UNLOCKED, preconditions still tracked. A retention period below 2555, an unexplained policy clear, or an un-reviewed lock is a FLAG. Any silent alteration → CRITICAL FINDING, page Rajesh immediately per task instructions.

Overall result: 1 PASS (Check 1 code layer), 3 ACTION-NEEDED (live-state items), 1 N/A. No INCIDENT or CRITICAL state was reachable from the sandbox — but absence of evidence is not evidence of absence. Checks 1 (live run confirmation) and 4 (retention-policy state) must be closed by Rajesh before this month's audit is complete. No status regressed versus the May audit; the ACTION-NEEDED items are unattended-context limitations, not detected control failures.

Expected-outcome reminder: through the pre-aging period (~2026 → ~2033) the purge job should fire monthly and touch zero rows — that zero-row PASS shape is expected, a missing run is the INCIDENT. The two non-trivial monthly checks remain (1) the job executed and (2) the GCS retention policy is unaltered.

Adjacent context (not a purge-audit finding): the 90-day production-operation bar for the retention-lock decision has now passed on the frontend-cutover driver (2026-04-27 + 90d = 2026-07-26) and passes on the real-persistence driver ~2026-08-06. The lock remains correctly deferred pending the three open Gate-5 preconditions and the Supporter Care erasure-rights question — see the 2026-07-18 review. Flagged here only so the lock decision is not forgotten; it is tracked by forms-portal-attachments-retention-lock-review's 60-day follow-up, not by this audit.

Follow-up: recommend a 7-day Cowork follow-up (forms-portal-purge-audit-followup-2026-07) to walk Rajesh through Checks 1, 2, and 4 live and back-fill verified statuses. Confirm whether the prior follow-ups (forms-portal-purge-audit-followup-2026-05) were completed — if live console verification has not been done since the control began, that gap should be prioritized.

2026-09-01 — August 2026 audit (automated run) — INCIDENT (probable): no purge executor exists

Executed by: Cowork scheduled task forms-portal-purge-job-audit-monthly (agent, unattended — Rajesh not present for the interactive walk-through this audit is designed around).

Same live-evidence boundary as prior runs, re-confirmed this month: gcloud, gsutil, psql, bq all absent from the sandbox; no cloud credentials in the environment; no Chrome extension connected; the built-in browser was denied navigation to console.cloud.google.com. Live Cloud Logging, Cloud SQL, and GCS Console state remain unreachable.

This month departs from the prior two entries. Rather than re-recording Check 1's code layer as PASS, this run searched the repository for the purge executor itself. It does not exist.

# Check Status Notes
1 Purge job ran in August 2026 INCIDENT (probable) — see finding below No executable purge job exists in the repo, and nothing in the codebase can emit the log label the audit filters on. Live Cloud Logging confirmation is still required to convert probable to confirmed, but the correct default posture is INCIDENT, not ACTION-NEEDED.
2 Reported counts plausible N/A Conditional on Check 1. With no run to report, there are no submissions_purged / audit_rows_purged / attachments_purged / dek_wrappers_retired fields to pull. (Note: even a healthy run would be expected to report zero through ~2033 — see the expected-outcome reminder in the July entry.)
3 Spot check one purged submission N/A Conditional on Check 2 returning a non-zero submission count. No purged submission_id exists to sample. Procedure retained from prior entries.
4 GCS retention policy on gpus-forms-attachments intact ACTION-NEEDED (live) Unchanged from July. Expected state is 2555 days, UNLOCKED per the 2026-07-18 DO-NOT-LOCK review. Assert: policy present at 2555d, unlocked, three Gate-5 preconditions plus the Supporter Care erasure-rights question still tracked. A period below 2555, an unexplained clear, or an un-reviewed lock is a FLAG; any silent alteration is a CRITICAL FINDING. Not verifiable from the sandbox.

Finding — the retention purge executor does not exist.

A full-repository search (all languages, config, shell scripts, cloudbuild.yaml files, runbooks, and git history) found:

  • No executor. No code anywhere compares retention_expires_at to the current time. Every occurrence in Python/SQL is write-side or schema-side: the column declaration (forms-backend/models.py:133), the create-time stamp (models.py:381-382, routes_phase2.py:302,308), and the schema (schema/001_init.sql:203,220). The string retention_expires_at < NOW() appears only in prose in this document and in generated MkDocs HTML.
  • No route. forms-backend/app.py:182-184 registers three blueprints; the only admin endpoints are read-only GETs (/api/admin/forms, /api/admin/submissions, /api/admin/audit). No purge endpoint.
  • No CLI or management script. No manage.py, purge*.py, or retention*.py in forms-backend/; scripts/ and gpus-reports/ contain nothing related.
  • No deletion code. The only DELETE/.delete() calls in forms-backend/ are YAML-reload field cleanup (yaml_loader.py:103-104) and a single blob delete on failed attachment upload (routes_phase2.py:497). Nothing touches submission_fields, DEK wrappers, or bulk attachments.
  • No scheduling. forms-backend/cloudbuild.yaml:94-131 only runs gcloud run deploy. No gcloud run jobs create, no Cloud Scheduler definition, no Terraform. The only Cloud Scheduler entry in inventory.yaml:1136-1140 is the ClamAV worker's /sweep-stuck sweep.
  • No log label. forms-backend/app.py:22-32 configures JSON logging with keys time, level, logger, msg only — no google.cloud.logging client, no CloudLoggingHandler, no job label anywhere in the codebase. The filter labels.job="retention-purge" appears only as a filter string written into this document (lines 198, 255). Nothing in the system can produce the log line the audit looks for.
  • No history of one. git log --all -S"retention-purge" returns only two commits, both editing this compliance document. No purge file was ever deleted.

Correction to the May and July entries. Both recorded Check 1's code layer as PASS on the basis that admin_purge_expired, retention_expires_at, idx_submissions_retention, and the purged status exist. Those are schema artifacts, not an executor. The July entry's phrasing — "Code path (admin_purge_expired audit action, retention_expires_at < NOW() predicate) is still present in forms-backend/models.py" — is inaccurate: models.py contains the enum value but no predicate and no query. idx_submissions_retention is a partial index whose only conceivable consumer is a purge scan that was never written. The prior PASS ratings should be read as schema scaffolding present, not purge job intact.

Corroboration. This is independently documented elsewhere in the repo: mkdocs-portal/docs/priorities/gpus-it-priorities.md:136 states that "the retention purge executor is unlocated," in the same bullet that records the absent DSAR/erasure path and the unlocked attachments bucket.

Why this is INCIDENT rather than ACTION-NEEDED. This audit's own escalation rule is "If no run found → INCIDENT, page on-call." Three audited months (May, July, August) have now recorded Check 1 as unconfirmed. If no executor was ever deployed, Check 1 cannot ever pass, and the monthly ACTION-NEEDED framing has been masking a standing control failure rather than a sandbox limitation. The evidence boundary is honestly stated: the agent cannot prove no run occurred, since a Cloud Run Job or Scheduler entry could in principle have been created directly in the console outside version control. But the repo cannot produce the artifact, and the burden now sits on demonstrating that a deployed executor exists rather than on demonstrating that it does not.

Compensating context (this is a control gap, not active data loss). The 7-year window (2555 days) cannot elapse for any production submission before ~2033 — earliest real persistence was 2026-05-08. So a missing purge job is currently retaining data that is not yet due for deletion; nothing has been over-deleted, and nothing is yet illegally over-retained under the 7-year policy. The exposure is that the control is undemonstrable today and would fail silently when it first matters, plus the adjacent live gap that there is no DSAR/erasure path for subject-initiated deletion requests arriving now.

On-call paging. The task instructs paging on-call for an INCIDENT. The unattended agent has no paging channel and does not send messages on Rajesh's behalf without explicit approval; this entry plus a Cowork task are the escalation path taken. Rajesh should treat this as requiring same-week action, not next-month follow-up.

Required actions for Rajesh (in order):

  1. Confirm or refute the finding live — Cloud Logging filter resource.type="cloud_run_revision" resource.labels.service_name="gpus-forms-backend" labels.job="retention-purge", widen the time range to all time rather than just August. Also check gcloud run jobs list and gcloud scheduler jobs list --location=us-central1 in gpus-infra for any executor deployed outside version control.
  2. If no executor exists — this is a confirmed standing control failure against the "Retention policy enforcement" control (and the AU-11 / retention mappings above). Open remediation to build and schedule the purge job: predicate on retention_expires_at < NOW() AND status != 'purged' (the partial index already supports it), set status purged, delete submission_fields, delete GCS attachments, retire DEK wrappers, write an admin_purge_expired audit row per submission, and emit structured Cloud Logging with the job=retention-purge label and the four count fields this audit expects. Note the schema's ON DELETE RESTRICT on submissions.form_id and submission_fields.submission_id and the never-hard-delete posture in schema/022_submission_revisions.sql:67-68 — the executor must respect the soft-delete model.
  3. Close Check 4 live — GCP Console → Cloud Storage → gpus-forms-attachments → Retention. Confirm 2555 days, UNLOCKED.
  4. Back-fill the prior follow-upsforms-portal-purge-audit-followup-2026-05 and -2026-07 appear never to have been completed. Live console verification has not been performed since this control began.

Overall result: 1 INCIDENT (probable), 1 ACTION-NEEDED, 2 N/A, 0 PASS. Status regressed versus the July audit — not because the system changed, but because this month's audit looked for the executor instead of the schema scaffolding.


Forms Portal Compliance Controls · v1.0 · 2026-04-18 · GPUS-IT · Classification: CONFIDENTIAL — Internal Use Only