Forms Portal — Incident Response Playbooks¶
Append to: mkdocs-portal/docs/response-plans/irp.md
Classification: CONFIDENTIAL — Internal Use Only
Version: 1.1 · 2026-08-31
Covers inventory IDs: gpus_forms_frontend, gpus_forms_backend, gpus_forms_db, gpus_forms_routing_worker, gpus_forms_approval_notify, gpus_forms_approval_notify_sub, gpus_forms_approval_notify_dlq, gpus_forms_approval_notify_dlq_sub
Each scenario below follows the established IRP format: Trigger → Severity → Initial response (first 15 min) → Investigation → Containment → Eradication → Recovery → Lessons learned.
FP-IR-01 — HappyFox credential leak¶
Trigger: HappyFox API key or auth code suspected exposed (git commit, shared screen, screenshot, phishing)
Severity: High — tickets can be spoofed; staff-submitted data exfiltratable
First 15 minutes:
1. Contact HappyFox support requesting immediate rotation (email support@happyfox.com + phone channel if available)
2. Disable the forms backend to stop further uses of the old credential: gcloud run services update gpus-forms-backend --region=us-central1 --no-traffic
3. Post #it-ops Slack channel with status
4. Check Cloud Logging for any unexpected HappyFox API calls in the last 24h
Investigation:
- gcloud logging read 'resource.type=cloud_run_revision AND resource.labels.service_name=gpus-forms-backend AND textPayload:"happyfox"' --limit=500 --format=json
- Cross-reference against expected submission volume from forms_submissions_total metric
- If anomaly: list all HappyFox tickets created in the exposure window via HappyFox admin API
Containment:
- HappyFox rotates — they provide new API key + auth code
- Update Secret Manager: echo -n '<new-key>' | gcloud secrets versions add gpus-forms-happyfox-api-key --data-file=-
- Repeat for auth code
- Disable old secret versions: gcloud secrets versions disable <old-version> --secret=gpus-forms-happyfox-api-key
Eradication:
- Force Cloud Run to pick up new secret: gcloud run services update gpus-forms-backend --region=us-central1 --update-env-vars=SECRET_REFRESH=$(date +%s)
- Re-enable traffic: gcloud run services update gpus-forms-backend --region=us-central1 --traffic=latest=100
Recovery: - Submit a test form, verify ticket creates with new credentials - Audit all tickets from exposure window; close/duplicate any fraudulent ones via HappyFox admin
Lessons learned:
- Review what caused exposure; update Secret Manager access policy if needed
- Document in post-incidents/ with root cause + timeline
FP-IR-02 — Bulk submission export anomaly (Wazuh rule 100025)¶
Trigger: Wazuh alert 100025 fires (≥10 submission exports by a single actor within window)
Severity: High — potential insider data exfiltration
First 15 minutes:
1. SOC dashboard Tickets tab auto-creates ticket (per existing SOC rules, level≥10)
2. Identify actor: query audit_log filtered on action='submission_exported' for last 1h
3. Validate: is this a known admin doing legitimate export? Or unexpected?
4. If unexpected: revoke actor's forms portal access immediately
Investigation:
SELECT occurred_at, actor_username, actor_ip, details, request_id
FROM audit_log
WHERE action = 'submission_exported'
AND occurred_at > NOW() - INTERVAL '24 hours'
ORDER BY occurred_at DESC;
Containment:
- Revoke actor's Okta session: Okta Admin → Users → [actor] → Clear user sessions
- Set users.is_active = false for the actor in forms DB
- If compromise confirmed: force Okta password reset + MFA re-enrollment
Eradication:
- Identify what was exported: details->>'submission_ids' in audit_log
- Determine what data was in those submissions via decrypt (admin-only, audited)
- Notify affected form owners per data classification policy
Recovery:
- If data is recoverable (e.g., only staff names exposed internally): close with notification
- If externally exposed: invoke DR-01 Data Breach playbook
Lessons learned:
- Adjust Wazuh rule 100025 threshold if false-positive threshold was wrong
- Consider rate-limiting /api/submissions export endpoints
FP-IR-03 — Decryption failure surge (Wazuh rule 100024)¶
Trigger: Wazuh alert 100024 fires repeatedly (multiple decrypt_failure events in audit_log)
Severity: Critical — possible KMS key rotation issue OR data tampering
First 15 minutes:
1. Check KMS key rotation state: gcloud kms keys versions list --key=gpus-forms-dek-wrapper --keyring=gpus-forms --location=us-central1
2. If a new key version was created recently (< 24h): rotation-related, benign-probable
3. If no recent rotation: possible tampering or DB corruption — escalate
Investigation:
- SELECT occurred_at, target_id, details FROM audit_log WHERE action='decrypt_failure' ORDER BY occurred_at DESC LIMIT 100;
- Which submission IDs fail? Isolated or widespread?
- Query submission_fields: do kms_key_version values match current KMS state?
- Check Cloud KMS audit logs: gcloud logging read 'resource.type=cloudkms_cryptokey AND protoPayload.methodName=~"Decrypt"'
Containment:
- If key version mismatch: the old key version may have been auto-destroyed. Check gcloud kms keys versions list for DESTROYED state
- If so: destroyed key version = affected submissions are unrecoverable. Identify scope.
- If DB corruption: restore from latest backup to gpus-forms-db-recovery clone, compare submission_fields rows
Eradication:
- If key issue: restore destroyed key version if still in 24h grace period (gcloud kms keys versions restore)
- If corruption: restore affected rows from backup into production
Recovery:
- Verify roundtrip decrypt on restored data: crypto.py roundtrip_test() endpoint
- Notify submitters if their data needs re-submission (only if un-recoverable)
Lessons learned: - KMS rotation policy review - Consider per-submission DEK escrow for disaster scenarios (trade-off with security)
FP-IR-04 — Cloud SQL compromise / suspected unauthorized access¶
Trigger: Anomalous queries in Cloud SQL audit logs, unexpected schema changes, or Wazuh correlation alerts
Severity: Critical
First 15 minutes:
1. Disable forms backend traffic: gcloud run services update gpus-forms-backend --region=us-central1 --no-traffic
2. Capture current Cloud SQL state: gcloud sql backups create --instance=gpus-forms-db --description="IR-FP-IR-04-$(date +%s)"
3. Review Cloud SQL connection logs: gcloud logging read 'resource.type=cloudsql_database AND protoPayload.authenticationInfo.principalEmail!~"gpus-forms-backend"'
4. Check for unexpected IAM database users: gcloud sql users list --instance=gpus-forms-db
Investigation:
- Compare current schema against expected (9 RLS policies, 12 tables, 4 roles): run the Q1-Q4 verification queries from Phase 1 Step 6
- Check audit_log for admin_reload_yaml or unexpected actions
- Check if postgres built-in user has been password-set (signals someone elevated): SELECT rolname, rolvaliduntil FROM pg_roles WHERE rolname='postgres';
Containment: - Rotate postgres built-in user to throwaway random (same cleanup pattern from Phase 1 Step 6) - Revoke any unexpected IAM database users - Rotate the backend service account key (there isn't one today — we use ADC — but if keys were created, destroy them) - Re-verify RLS policies are intact; re-apply 002_rls.sql resume file if tampered
Eradication: - If data was altered: restore from pre-incident backup clone - Verify integrity by sampling decrypt roundtrip on submissions from the affected window
Recovery: - Re-enable traffic after all verifications pass - Post-mortem: how did attacker get in? Review VPC firewall rules, private IP exposure, service account key hygiene
Lessons learned: - Add Cloud SQL admin-activity alerts to Wazuh ruleset if not present
FP-IR-05 — KMS key compromise¶
Trigger: Cloud KMS key version showing unexpected decrypt calls, or compromise suspected
Severity: Critical — all submission data encrypted with that key version at risk
First 15 minutes:
1. Disable affected KMS key version: gcloud kms keys versions disable <version> --key=gpus-forms-dek-wrapper --keyring=gpus-forms --location=us-central1
2. Disable forms backend: gcloud run services update gpus-forms-backend --region=us-central1 --no-traffic
3. Capture KMS audit logs for the compromise window
Investigation:
- gcloud logging read 'resource.type=cloudkms_cryptokey AND resource.labels.key_ring_id=gpus-forms' --freshness=7d
- Identify all Decrypt calls + their principals
- Cross-reference submission_fields rows with matching kms_key_version — these are the affected submissions
Containment:
- Create new KMS key version: gcloud kms keys versions create --key=gpus-forms-dek-wrapper --keyring=gpus-forms --location=us-central1
- Update config.py KMS_KEY if key rotation behavior changed
- Redeploy backend pointing at new version
Eradication: - Re-encrypt all affected submissions under new key version (batch job — requires admin-level process, NOT automatic) - Once all migrated, destroy compromised key version (30-day grace period)
Recovery: - Verify roundtrip on re-encrypted submissions - Re-enable traffic
Lessons learned:
- Audit who had access to the key; review IAM bindings on gpus-forms-dek-wrapper
- Consider HSM-backed keys if this recurs (increased cost)
FP-IR-06 — Forged or replayed approval decision¶
Trigger: A travel authorisation shows an approval nobody remembers making — an approver
says they did not decide a step recorded against them, a status changed without a matching
approval_decision_recorded audit row, or decided_by_email does not match the
resolved_okta_email snapshot on the same row.
Severity: High — an unauthorised state transition on a live financial authorisation.
approval_access.py exists because this is the threat; nothing above this line covered it.
Detection here is WEAK, and the gap is the finding
- A denied attempt is recorded (
audit_log.action = 'auth_failure',details.endpoint = 'approval_decision') and IS shipped to Wazuh —auth_failureis insoc_shipper_allowlist.json. Butapproval_access's reason vocabulary (no_matching_step,round_not_in_approval,no_token_email) is not inauthz_reasons.json, so rules100031–100036do not match it. It lands on rule100030at level 3 — below the level ≥ 10 SOC auto-ticket threshold. Nobody is paged for a denied approval attempt. - A successful decision writes
approval_decision_recorded, which is inmodels.pyAUDIT_ACTIONS(33 values) but appears in neithership_actions(17) norexcluded_actions(14) insoc_shipper_allowlist.json— 31 of 33 declared.approval_decision_recordedandapproval_notification_sentare undeclared, so a recorded approval decision never reaches the SOC stream at all. The allowlist's own header says "a new enum value forces a ship decision"; for these two that decision was never taken. - ASVS fact G:
forms_appholdsUPDATEonapproval_stepsand there is noREVOKEanywhere informs-backend/schema/. A decision can be rewritten, and the row carries no evidence that it was.
So the realistic detection is a human noticing — an approver disputing a decision
attributed to them, or Kevin querying at step 3. That is the honest statement. Do not
read the queries below as monitors; they are forensics you run after someone tells
you. Raising the two shipping gaps is PRG-030 / PRG-031 work, not an IR step.
First 15 minutes:
1. Do not modify the row. approval_steps is mutable and carries no history; an
investigative UPDATE destroys the only copy of the disputed state.
2. Snapshot the round before anything else:
SELECT * FROM approval_steps WHERE submission_id = :id ORDER BY version, step_index;
SELECT * FROM submission_approvals WHERE submission_id = :id ORDER BY version;
IN_APPROVAL, tell the coordinator (Shereyll) to hold — do not
let step 2 or step 3 act on a disputed step 1.
4. If the trip is already booked, escalate to Kevin the same hour. Booking against a
forged authorisation is the loss, not the database row.
Investigation:
-- 1. The immutable half. Does a decision row have a matching audit row?
SELECT s.step_index, s.status, s.decided_by_email, s.decided_at,
s.resolved_okta_email, a.id AS audit_id, a.actor_username, a.actor_ip,
a.details->>'action_taken' AS audited_action, a.occurred_at
FROM approval_steps s
LEFT JOIN audit_log a
ON a.action = 'approval_decision_recorded'
AND a.target_id = s.submission_id::text
AND (a.details->>'step_index')::int = s.step_index
WHERE s.submission_id = :id
ORDER BY s.version, s.step_index;
audit_id NULL on a decided step — the decision was written outside the endpoint.
The endpoint writes both in one transaction (approval_decide.py:494-499), so this
combination cannot arise from the application path. Treat as confirmed tampering.
* decided_by_email ≠ resolved_okta_email — the four conditions require equality
(condition 3), so the endpoint cannot produce this either.
* audited_action ≠ the recorded status — the row was rewritten after the audit
row was fixed.
-- 2. Denied attempts against this submission, at any level.
SELECT occurred_at, actor_username, actor_ip, details->>'reason' AS reason
FROM audit_log
WHERE action = 'auth_failure'
AND details->>'endpoint' = 'approval_decision'
AND target_id = :id
ORDER BY occurred_at;
actor_ip may be the Cloud Run front-end rather than the caller — the proxy bug that
blocks rules 100037/100038 also degrades this column. Corroborate from Okta, not here.
# 3. The replay path, which is BENIGN and must be excluded first.
gcloud logging read 'resource.labels.service_name="gpus-forms-backend"
AND textPayload:"approval_decision.replay"' --freshness=30d
409 already_decided is the correct handling of a double-submitted decision by the
same approver. Rule it out before treating a duplicate as an attack.
Containment:
- Revoke the suspected actor's Okta session (Okta Admin → Users → [actor] → Clear user
sessions) and set users.is_active = false in the forms DB.
- If the identity is forms_app itself rather than a person — i.e. the write came through
the backend with no audit row — the exposure is the Cloud Run service account, not a
user. Rotate nothing yet; capture the revision first:
gcloud run revisions list --service=gpus-forms-backend --region=us-central1
- Do not "correct" the step by editing it. If the round must not proceed, block it at
the round: set submission_approvals.state = 'BLOCKED' with a blocked_reason naming
this incident. That is a visible stop; a rewritten step is another undetectable write.
Eradication:
- Establish the true decision from the approver directly, in writing, out of band.
- Record the correction as a new audit row and a fresh round version — never by
amending version 1. version is 1 everywhere today, so this is the first real use of
the column; it is the right one.
- If the entry point was a compromised Okta account, continue into FP-IR-07.
Recovery:
- Re-run the round from the step below the disputed one.
- Confirm the notification columns advanced (§ How you know it's working, check 2).
- If a trip was booked on the forged approval, that is a finance matter — hand to Kevin
with the snapshot from step 2 attached.
Lessons learned:
- Ship approval_decision_recorded to Wazuh. Until it is in ship_actions, every
successful approval decision in this estate is invisible to the SOC. Regenerate via
soc/forms-authz-detection/gen_authz_detection.py; do not hand-edit the allowlist.
- Add approval_access's reasons to authz_reasons.json so a denied approval
attempt tiers above level 3.
- Re-open the ASVS fact G question: which approval_steps columns are append-only.
notification_sent_at and administrative re-dispatch are the legitimate post-decision
writes; status, decided_by_email, decided_at and comment are not.
FP-IR-07 — Approver account compromise (Okta)¶
Trigger: Okta reports suspicious authentication for an account in
approval_role_members — impossible travel, MFA fatigue, a phishing report, or an
approver saying they did not sign in.
Severity: High — for the approval workflow, Okta is the whole control.
Why this is a separate playbook from FP-IR-02
FP-IR-02 revokes an Okta session for a data-export actor. It does not cover an
approver, and irp.md describing FP-IR-01–05 as including "Okta token compromise"
was wrong — grep -ic approval forms-portal-ir.md returned 0 before this
section existed.
The reason this needs its own path is ASVS fact C: the approval link
(https://forms.greenpeace.us/approvals/<submission_id>) carries no token, no
query parameter and no secret. It is not a bearer credential and cannot be
revoked — there is nothing to revoke. Authorisation is recomputed server-side on
every call against the caller's Okta-asserted identity. Therefore: killing the
Okta session is not one containment option among several. It is the only one.
There is no link to expire, no token to rotate, no per-request secret to burn.
First 15 minutes: 1. Okta Admin → Users → [approver] → Clear user sessions, then suspend the account. Until this is done the attacker can decide any step dispatched to that identity. 2. Enumerate what is currently exposed to that identity — the steps they could decide right now:
SELECT s.submission_id, s.version, s.step_index, s.status, s.dispatched_at
FROM approval_steps s
JOIN submission_approvals r
ON r.submission_id = s.submission_id AND r.version = s.version
WHERE lower(s.resolved_okta_email) = lower(:approver_email)
AND s.status = 'DISPATCHED'
AND r.state = 'IN_APPROVAL';
approval_access § the four
conditions); DISPATCHED + IN_APPROVAL is the pair that makes this list live.
3. Check whether any of them were decided during the compromise window — run FP-IR-06's
query 1 against each submission_id returned.
Investigation:
- Okta System Log is the primary source, not the forms portal. The portal records
token_email from an already-validated JWT; it cannot tell a legitimate sign-in from a
compromised one.
- sql
SELECT occurred_at, action, target_id, details->>'action_taken' AS act, actor_ip
FROM audit_log
WHERE actor_username = :username
AND occurred_at >= :window_start
ORDER BY occurred_at;
Covers reads (submission_viewed) and — once the shipping gap in FP-IR-06 is closed —
decisions. Today approval_decision_recorded is in the table but not in the SOC
stream, so this query is the only place it surfaces.
- Snapshot authorization does not revoke (ASVS fact D). Removing the person from
approval_role_members does not withdraw their access to steps already dispatched
— resolved_okta_email was snapshotted at dispatch. Suspending the Okta account is
what closes it; the roster edit is bookkeeping.
Containment:
- Okta: suspend, clear sessions, force password reset + MFA re-enrolment.
- For every step from query 2 that must not proceed: block the round
(submission_approvals.state = 'BLOCKED', blocked_reason naming the incident).
Do not edit the step.
- Do not set FORMS_APPROVAL_RECIPIENT_OVERRIDE to "protect" the queue. It is
global — every approval mail estate-wide would divert, and unrelated approvers
would silently stop hearing about real requests.
Eradication: - Restore the account only after Okta confirms the initial access vector is closed. - Re-dispatch each blocked round; re-dispatch is how revocation happens at all (fact D). - If any decision was made by the attacker, run FP-IR-06 for that submission.
Recovery:
- Confirm the approver can reach /approvals/<id> and that the decision buttons render
(the SPA reads can_decide, which the handler recomputes server-side).
- Verify the re-dispatched steps show notification_published_at and
notification_sent_at set.
Lessons learned:
- The roster is the blast radius. approval_role_members should be reviewed on the same
cadence as any privileged group; today nothing reviews it.
- Fact D deserves an operational control: a documented "revoke an approver" procedure
that names Okta suspension as step 1 and the roster edit as step 2, in that order.
FP-IR-08 — Notification pipeline stall (published, never sent)¶
Trigger: approval_steps.notification_published_at is SET while
notification_sent_at is NULL, on a step older than a few minutes. Equivalently: an
approver says they were never told about a request the portal shows as DISPATCHED.
Severity: High — silent. The rows read as correct, the SOC is quiet, and the request simply never moves. This has happened at least three times:
| Date | What stalled | Mechanism |
|---|---|---|
| 2026-08-18 16:33:43 | submission bb575538 |
dispatch_step sent mail from Cloud Run, whose Postfix reach is nil — [Errno 111] Connection refused. Fixed by 1887fa0, which made the backend a publisher and moved the send to MAPLE rather than opening Postfix to the VPC |
| 2026-08-18 | every published pointer | Both approval drains were unreachable (194a9e2). drain_approval_subscription was the last statement of the worker loop, after three continues — including DeadlineExceeded, the normal idle path at PULL_TIMEOUT_S=30. drain_approval_dlq sat behind a bare return in an exception branch. Both had executed zero times. The boot log reported the subscription as configured throughout |
| 2026-08-19 | rounds 30a286e1, 6418f160 |
Both went terminal (APPROVED / RETURNED_FOR_REVISION, tgarg@greenpeace.org) before migration 019 added outcome_notified_at. Same class one level up: two travellers were never told the outcome. The deploy does not reach back; they still need a manual backfill |
The 2026-08-18 defect is why 'no alerts' is not evidence
It survived verification because the drains logged nothing on an empty pull —
"not called", "called and idle" and "called and timed out" were indistinguishable,
and the absence of approval.drain_failed was read as proof the IAM binding worked.
It proved nothing. The heartbeat exists so that silence is now falsifiable.
The four Pub/Sub assets carry monitoring_status: planned. There is no DLQ-depth
alert. A message stranded in gpus-forms-approval-notify-dlq today is visible only
to someone who goes and looks. Detection for this scenario is a person running the
query below — usually because R2 flagged the request as stalled to Shereyll, or an
approver asked. That is the gap; it is not papered over here.
First 15 minutes: 1. Is the worker alive at all?
Expected everyDRAIN_HEARTBEAT_S (300s), reporting iterations / messages / errors
since boot, and immediately on the first record after a restart.
Absent ⇒ the drain is not running. Not "running and idle" — those were made
distinguishable precisely because confusing them is how the 2026-08-18 defect shipped.
Expect approval.dlq_drain.heartbeat alongside it.
2. Scope it — one step, or everything?
SELECT submission_id, version, step_index, status,
notification_published_at, notification_sent_at
FROM approval_steps
WHERE notification_published_at IS NOT NULL
AND notification_sent_at IS NULL
ORDER BY notification_published_at;
boot approval_recipient_override=OFF — LIVE DELIVERY to approvers.
If it reads ACTIVE to=…, no approver is receiving anything — steps still reach
DISPATCHED and notification_sent_at is still stamped. Absent entirely ⇒ the worker
did not reach its boot banner or is running an older build, and unknown is not OFF.
Investigation:
Read the column pair as the platform doc's diagnostic table does — it distinguishes four states and only one of them is a mail problem:
| published | sent | Meaning | Where to look |
|---|---|---|---|
| NULL | NULL | Never published; the dispatch did not complete or the publish failed | Cloud Run backend logs. Row is DISPATCHED and recoverable — this is the state that says so |
| SET | NULL | This scenario. Published; the worker has not sent | Heartbeat, then the DLQ subscription |
| SET | SET | Handed to the relay. Not delivery (ASVS fact E) — bounces downstream are invisible to the portal | /var/log/maillog on MAPLE |
| NULL | SET | Should not occur | Data-integrity question, not a mail question |
# Is it stranded in the DLQ, or still retrying?
gcloud pubsub subscriptions pull gpus-forms-approval-notify-dlq-sub \
--limit=10 --format=json # DO NOT --auto-ack while investigating
{submission_id, version, step_index} only. No rendered
body, no address, no amount. The message tells you which step stalled and nothing about
its content; that is deliberate (Pub/Sub messages are durable and retained).
-- Did it actually go out, and to whom?
SELECT created_at, target_id, details->>'intended', details->>'delivered_to',
details->>'redirected'
FROM audit_log
WHERE action = 'approval_notification_sent' AND target_id = :id
ORDER BY created_at DESC;
approval_notification_sent row for a step whose notification_sent_at IS
stamped ⇒ the two facts diverged, which the single-transaction write is supposed to
make impossible. Investigate the worker, not the mail.
Containment:
- If the worker is down: systemctl restart gpus-forms-routing-worker, then confirm the
heartbeat within one iteration and the override=OFF banner. Restart alone is not the
fix if the cause was reachability — read the loop, not the log level.
- If the override is ACTIVE and should not be: clear it in
/etc/gpus-forms-routing.env. The backend's override is read per send; the
worker's is a module constant and needs the restart. Confirm the banner afterwards.
- While stalled, tell the affected approvers directly. The request is real and pending;
they are the only ones who can move it.
Eradication:
- Root-cause into one of: reachability (statement placement — check that the drains still
sit ahead of the routing pull and at duty 0 of sweep(); the comments there say
ORDER IS LOAD BEARING), IAM on the subscription, or SMTP on MAPLE.
- Re-drive the DLQ once the cause is fixed. The send is guarded
AND notification_sent_at IS NULL, so a redelivery cannot double-send a step already
sent.
Recovery:
- Both columns set, published before sent, for every affected step.
- Terminal rounds with outcome_notified_at still NULL are the second recovery query —
every traveller whose request is finished and who was never told:
SELECT submission_id, version, state, outcome_notified_at
FROM submission_approvals
WHERE state IN ('APPROVED','RETURNED_FOR_REVISION','DECLINED')
AND outcome_notified_at IS NULL;
30a286e1 and 6418f160 are expected here until backfilled.
Lessons learned:
- Promote the four Pub/Sub assets off monitoring_status: planned. A DLQ-depth alert
is the difference between this scenario having a detection signal and not having one.
- The heartbeat is the model: state the normal case out loud at a readable cadence, so
silence becomes evidence. Anything else added to this pipeline should do the same.
- Backfill 30a286e1 and 6418f160.
FP-IR-09 — BLOCKED round accumulation¶
Trigger: submission_approvals.state = 'BLOCKED' on one or more rounds. In practice:
a traveller asks why nothing has happened, or someone runs the query below.
Severity: Medium — no data is exposed and nothing is wrong with the estate's security
posture. It is a availability and integrity-of-process failure: the request stops, and
there is no code path out of BLOCKED.
There is no detection, and no exit. Both are the finding.
- No detection.
BLOCKEDwrites noaudit_logrow, no Wazuh rule matches it, and no report counts it. R2 is a chase-list of stalled requests, R5 counts volume and cycle time, R1 groups submissions by bounded keys — aBLOCKEDround is in none of their numerators as a distinct state. The only way anyone learns is that a person asks. Writing "monitor for BLOCKED" here would be inventing a monitor; instead, run the query below on a schedule you actually keep. - No exit. There is no unblock endpoint.
WITHDRAWNhas been a legal state since migration 014 and no code path has ever set it — migration 021 set it by hand on four test chains. So aBLOCKEDround is stranded until someone edits the database.
Seven conditions produce BLOCKED (approval_resolver.resolve_chain_core; 5 and 6 are
re-evaluated at dispatch, where the same failure blocks the round and publishes nothing):
| # | Condition | Most likely cause |
|---|---|---|
| 1 | form_id has no chain configured |
A new form with an approval expectation and no chain |
| 2 | Submission row not found | Data-integrity problem — investigate as one |
| 3 | No SMTApprover value in searchable_values |
Field removed, renamed, or submitted blank |
| 4 | Display value has no row in approval_role_display_map |
The common one. A rename in forms/pulldowns.yaml not mirrored in the map |
| 5 | A role has zero active members | Departure, or is_active cleared |
| 6 | A role has more than one active member | Two people seeded for one role; ambiguous |
| 7 | Every step resolved to a skip | No approver remains after SKIPPED_SELF / SKIPPED_DUPLICATE |
The join is exact string equality on a human display name — no strip(), no
casefold(), no normalisation. That is deliberate: quietly repairing a near-miss would
hide the drift BLOCKED exists to surface. Felicity Vonsuck is one word; an earlier
draft carried Felicity Von Suck, which would have resolved to nothing on every
submission naming her.
First 15 minutes:
1. Find them all, and read blocked_reason — it records the unmatched string verbatim,
and for condition 6 it names the competing addresses:
SELECT submission_id, version, state, blocked_reason, created_at
FROM submission_approvals
WHERE state = 'BLOCKED'
ORDER BY created_at;
Investigation:
For condition 4 — compare the two vocabularies directly:
SELECT DISTINCT r.blocked_reason
FROM submission_approvals r
WHERE r.state = 'BLOCKED';
SELECT display_value, role_key FROM approval_role_display_map ORDER BY display_value;
python3 -c "import yaml;d=yaml.safe_load(open('forms/pulldowns.yaml'));\
print([p['values'] for p in d if p['name']=='Travel SMT Approver'])"
For conditions 5 and 6:
SELECT role_key, count(*) FILTER (WHERE is_active) AS active,
string_agg(okta_email, ', ') FILTER (WHERE is_active) AS members
FROM approval_role_members GROUP BY role_key ORDER BY role_key;
travel_admin_ops and
travel_final are fixed roles resolved from this table directly and never pass through
the display map — a problem there is always 5 or 6, never 4.
Containment: - There is nothing to contain: a blocked round has published nothing and mailed nobody. The correct action is to stop new rounds hitting the same condition — fix the map row or the roster before clearing the backlog, or you will re-block them. - Never default a blocked round to a fallback approver and never drop the step silently. The visible stop is the design.
Eradication:
- Fix the cause: add the missing approval_role_display_map row (condition 4), or
correct approval_role_members to exactly one active member (5, 6), or configure the
chain (1).
- Then re-resolve the affected rounds. There is no endpoint for this — it is a manual
re-run of resolve_chain_core plus a state write, performed as maple-agent, one round
at a time, with the before-state captured first.
- If a blocked request is stale enough to be moot, WITHDRAWN is the honest terminal
state — but note it must be set by hand, it emits no submitter notification
(_SENDABLE_OUTCOMES has no WITHDRAWN and the renderer has no branch for it), and it
records neither actor nor reason. Tell the traveller yourself. Do not delete the row.
Recovery:
- Round back to IN_APPROVAL, step 1 DISPATCHED, both notification columns set.
- Re-check the BLOCKED query returns nothing unexpected.
Lessons learned:
- The BLOCKED query belongs on a schedule. Until something counts blocked rounds,
the detection for this scenario is "a traveller complains", and that is what this
section says rather than pretending otherwise.
- A rename in forms/pulldowns.yaml touching Travel SMT Approver must be mirrored in
approval_role_display_map in the same change. Consider a coverage-check-style guard
comparing the two, in the spirit of scripts/check-component-coverage.py.
- The undesigned withdraw endpoint (four open design questions, per
platform/travel-approval-workflow.md § What is NOT built) is what would give this
scenario a supported exit. When built it must add an approval_withdrawn audit action.
References¶
- Existing IRP:
mkdocs-portal/docs/response-plans/irp.md - Approval workflow, state machine and failure modes:
platform/travel-approval-workflow.md - ASVS L2 scope statement, declared facts A–G:
security/asvs-scope-forms-approval.md - Forms authz detection (Wazuh rules 100030–100039, shipper allowlist):
soc/forms-authz-detection/ - Direct DB access as
maple-agent:infrastructure/runbooks/forms-db-access.md - Tabletop playbooks:
mkdocs-portal/docs/response-plans/tabletop-playbooks.md - SOC auto-ticketing thresholds: SOC dashboard Tickets tab
- Cloud SQL IAM DB user recovery (if postgres password lost): recreate via
gcloud sql users set-password postgres --instance=gpus-forms-db --password=<new>with project owner credentials