Skip to content

OWASP ASVS L2 — Scope Statement: Forms Portal Approval Workflow

Classification: CONFIDENTIAL — Internal Use Only Document: security/asvs-scope-forms-approval.md · v1.1 · 2026-08-18 · GPUS-IT Status: SCOPE ONLY. This is not an assessment, not a gap analysis, and not a remediation plan. It defines the boundary those documents will work inside.


1. What this document is, and why it exists now

This scope statement is written before three of its four endpoints exist. That is deliberate, and it is the point of the document rather than a caveat on it.

The travel approval workflow introduces the first place in the forms portal where one member of staff reads another's submitted personal data, and the first place where a recorded decision carries financial weight. Those two properties are what pull the workflow into ASVS L2 territory. Writing the boundary now means the endpoints are built against a stated security contract instead of being assessed against one afterwards, when the cost of a structural change is a migration rather than an edit.

It also exists to declare known architectural facts up front. §4 states seven of them plainly. Each is true today, most are estate-wide and predate this workflow, and every one of them would otherwise be discovered by an assessor and written up as a finding. A fact an assessor discovers is a finding; a fact the system owner declares with its consequence is a scope boundary. The difference is not cosmetic — it determines whether the assessment spends its effort on the decisions that were actually made, or on rediscovering the context those decisions were made in.

The prior art here is direct. VLN-011 documented a control that returned success while doing nothing for 37 submissions, and the reason nobody caught it was that every check an operator would think to run came back green. Several of the facts in §4 describe conditions with that same shape: a mechanism that is present, that looks like a control, and that is not one. Naming them here is the cheapest available mitigation.


2. In scope — four endpoints

The assessment covers four API endpoints.

Status, 2026-08-19. §2.1, §2.2, §2.6 (the approver list) and §2.7 (the submitter status view) are BUILT. §2.3 is SUPERSEDED by §2.7 and will not be built. §2.4 remains unbuilt and undesigned, which is what still blocks any edit-or-resubmit path — §2.7 is read-only for that reason. The prose in each subsection below states what the endpoint must be; where an endpoint now exists, it also names the module that implements it, and the as-built behaviour should be read against §4's facts rather than assumed from this text.

Neither built endpoint is deployed — the code is committed but not pushed, so no Cloud Build trigger has run. Assessment against the live service must wait for a deploy.

2.1 GET /api/submissions/<id>/approval-view — BUILT 2026-08-19

The approver's read of a submission awaiting their decision. It is the first non-submitter decrypt of submission PII anywhere in the portal, returning all thirteen fields of the travel request together with the decisions and comments recorded by prior steps.

Its authorization model is the security-critical property. Access is granted by comparing the caller's Okta identity against approval_steps.resolved_okta_email — the snapshot, frozen at dispatch — and not against live membership in approval_role_members. The snapshot is the authorization target by design; see fact D in §4 for the consequence, which is that this endpoint does not honour a mid-flight membership change.

The snapshot is not, however, created at dispatch, and an assessment that assumes it is will mis-read the control. resolved_* is written for every step of the chain at persistence and merely re-written for the one step being dispatched; a step at status PENDING already carries its approver's address. A populated resolved_okta_email therefore means assigned, never may read. What separates the two is status = 'DISPATCHED', ANDed with the address comparison — see fact D and the corrected header of 014_approval_workflow.sql.

This endpoint decrypts. Everything the assessment says about key handling, audit, and least disclosure applies here first.

As built: routes_phase2.approval_view, authorized by approval_access.resolve_approver_access — the single shared check, which is the whole of the access control given fact A. Every call is logged and audited, granted or denied. All authorization failures return one indistinguishable 403, including for a submission that does not exist: the id identifies without authorizing (fact C), so a 404/403 split would make the endpoint an oracle for which UUIDs are live travel requests.

2.2 POST /api/submissions/<id>/approval-decision — BUILT 2026-08-19

The approver's decision: approve, return for revision, or decline. It writes decided_at, decided_by_email and comment to the approval_steps row.

Non-repudiation is the product of this endpoint, not a property of it. A travel approval is a financial control; the record of who approved what, and when, is the entire reason the workflow exists rather than an email thread. That places the integrity and completeness of the write above its availability in the assessment's priority ordering.

As built: routes_phase2.approval_decision + approval_decide. The decision, the voiding of remaining steps, the round transition and the audit row are ONE transaction; the step UPDATE is guarded WHERE status = 'DISPATCHED', a compare-and-swap rather than a check-then-act, so two concurrent decisions cannot both land. dispatch_step runs after that commit, never inside it. The audit row is written in the same transaction precisely because of fact G — audit_log is the immutable half of the record and a decision must not commit without it.

Known gap, deliberate: RETURN and DECLINE do not notify the submitter. There is no submitter-facing path to notify them into while §2.3 is unbuilt, and the response says so explicitly (submitter_notified: false) rather than letting the SPA imply otherwise. Second gap: the audit row records that a comment of a given length was recorded, not its text — audit_log is shipped to Wazuh and an approver's free text may describe the trip, so the text stays in approval_steps, which is mutable. Whether the comment text needs the same immutability as the fact of the decision is an open audit-scope question.

decided_by_email is stored separately from resolved_okta_email on purpose, and the two must be independently queryable. Divergence between them means somebody other than the assigned approver recorded a decision — an authorization defect — and the schema is shaped so that this remains detectable after the fact rather than being collapsed into a single field at write time.

2.3 GET /api/submissions/<id>/revision-view — SUPERSEDED by §2.7

Not built, and will not be. This endpoint was specified as the submitter's read of their own submission while the approval record is in RETURNED_FOR_REVISION. What was built instead is §2.7, the same read with the state gate removed and a wider purpose: where the request is, at any state.

§2.7 is a strict superset — every call §2.3 would have authorized, §2.7 authorizes. The state gate is the whole of the difference, and B-revised in §4 records why it was dropped. The original text is kept below because the reasoning it contains about the state gate is what B-revised argues against, and an assessor should be able to see both.

The submitter's read of their own submission, available only while the approval record is in state RETURNED_FOR_REVISION. It is a decrypt, but of the caller's own data, which makes it a materially lighter disclosure question than §2.1. Its sensitivity is in the state gate: the same call outside that state must fail, because the no-read-after-submit policy (fact B) otherwise stands.

Inventory registration

The notification transport in scope here is gpus_forms_approval_notify (topic gpus-forms-approval-notify) in inventory.yaml under cloud_services, with gpus_forms_approval_notify_sub, gpus_forms_approval_notify_dlq and gpus_forms_approval_notify_dlq_sub. Registered 2026-08-26. The pointer-only payload rule below is the reason those entries record what the message may not carry.

2.4 POST /api/submissions/<id>/revise — NOT BUILT, PROVISIONAL

Creates a new submission linked to the returned one, forming a revision chain.

This endpoint's design is not settled and is explicitly provisional in this scope. The open questions are consequential enough that assessing it now would assess a guess: whether a revision is a new submissions row or a new version on the existing approval record; whether prior steps are re-dispatched or preserved; how the retention clock (retention_days: 2555) applies across a chain; and what the durable identifier is for a request that exists as three rows. Until those are answered, the assessment should record it as in-scope but undesigned rather than assess it against assumptions this document does not make.

SUPERSEDED 2026-08-26 — the questions are answered and the path is BUILT

Kept in place rather than rewritten, per this document's own convention for §2.5. Everything above was true when written and every open question it lists has since been settled by migration 022 (2026-08-25):

Open question above Answer
New submissions row, or a new version on the existing round? A new row, carrying submissions.parent_submission_id (Option A). Every submission still has one round at version 1.
Are prior steps re-dispatched or preserved? The revision gets a fresh chain that restarts at step 1; the parent's steps are preserved on the parent, which stays RETURNED_FOR_REVISION.
How does the retention clock apply across a chain? Each row is its own submission with its own clock. No chain-level retention concept was introduced.
What is the durable identifier for a request that exists as three rows? There isn't one, by decision. Each row is durably identified by its own submission_id; supersession is derived from the existence of a child row, with no stored flag.

The endpoint was never built in the shape named in the heading. There is no POST /api/submissions/<id>/revise. The path is GET /api/submissions/<id>/revision-source for the prefill, and the ordinary POST /api/submissions carrying parent_submission_id, with revision_access.resolve_revision_access run on both — owner, round state RETURNED_FOR_REVISION, and not-already-revised.

This heading's staleness has already cost something. It is cited by SubmissionStatus.tsx as the reason the portal offered no revise control, and that reasoning outlived it by a day: the page went on telling returned travellers the request "cannot be edited" while the path was live and unreachable. See VLN-047 — the surfaces that cite a provisional section are part of what has to be revisited when it stops being provisional.

2.5 Supporting components — BUILT and verified

Three components exist and are verified against the live system. None is wired into any request path. They are in scope because the endpoints above will depend on them, and because their properties constrain what those endpoints can do.

Component State
Migrations 014 + 015 Applied. Four tables, RLS enabled on all four, forms_app grants, composite FK from approval_steps to its parent round. Verified by information_schema / pg_constraint readback, not by the apply succeeding.
approval_resolver.py 470198c, amended 653da8e. 13/13 tests. Resolves the chain and writes nothing.
approval_dispatch.py 49d0f49. 15/15 tests. Dispatches one step and sends its notification.

Superseded 2026-08-19. The paragraph that stood here said the resolver and dispatcher were unreferenced by any route and that no execution path ran from an HTTP request to either module. That is no longer true: finalize_submission calls _run_travel_approval, which persists the chain and dispatches its first step, and approval_decision calls dispatch_step after each APPROVE. Both modules are now on live request paths.

2.6 GET /api/approvals — BUILT 2026-08-19

Not in the original four. It is the approver's queue: which chains are waiting, on whom, and for how long. It is in scope because it is a new read surface with its own scope rule, not because it discloses content — it returns chain state only, no field values and no decrypt, and the module that builds it imports no crypto and names no ciphertext column (asserted by test, mirroring the routing worker's renderer).

Two scope rules, and the second is the one to assess. Every authenticated caller sees chains with a step dispatched to them plus chains they have decided a step on. An active member of role_key = 'travel_coordinator' (approval_role_members, seeded by migration 017) additionally sees every IN_APPROVAL chain.

Coordinator scope is deliberately not an exemption from §2.1. A coordinator sees that a chain exists and where it is stuck; opening any one of them runs resolve_approver_access with no coordinator branch and returns the same generic 403 to a coordinator who is not the assigned approver. The asymmetry — seeing that a request exists is not seeing what it says — is what makes the wider list scope acceptable, and it is the property to test.

The role is deliberately not FORMS_ADMIN_EMAILS, which grants the global admin role across every form in the estate plus the audit log. travel_coordinator appears in no chain configuration, so no step can ever resolve to it: read scope only, structurally.

2.7 GET /api/submissions/<id>/status — BUILT 2026-08-19

The submitter's read of their own request: the round state, every step with its label, approver display name, status and decision time, the comment on returned and declined steps, and the request's own field values. Read only — there is no edit and no resubmit, because the revision path (§2.4) is still undesigned.

Replaces §2.3. It is the same read with the state gate removed; see B-revised in §4 for why, and for the fact that this is the portal's second decrypt path.

Its authorization is a THIRD rule, and that is the property to assess. approval_access.resolve_submitter_access lives beside resolve_approver_access in the same module but is a separate function with a separate return type. The two answer different questions — "is this caller the subject of this record" versus "may this caller act on this step" — and were deliberately not folded together, because one function returning either a submission or a step, with a reason vocabulary half of which is meaningless to each caller, is how a caller ends up acting on the wrong half.

The rule is two conditions and no more: a non-empty token email, and submissions.submitter_email equal to it after case and whitespace normalization on both sides. No state condition, no admin bypass, no role check. An admin reading someone else's submission is a different act with a different justification and belongs at /api/admin/submissions.

Comment visibility is narrower than the step list suggests: only RETURNED and DECLINED steps surface their comment. An approve-with-a-note is an aside between approvers, and the submitter-outcome email makes the same distinction, so the page and the mail cannot tell the traveller different stories.

The uniform 403 applies unchanged — "not yours" and "no such submission" are indistinguishable, on the same enumeration-oracle reasoning as §2.1, which does not weaken because the reader is a submitter. purged answers 410 rather than 403: the caller is authorized and the content has passed retention, and hiding that behind an authorization error would tell them they had lost access when they had not.


3. Out of scope

The travel request form itself. Live and verified against the database on 2026-08-18: thirteen fields, one email_template action, five pulldowns resolving, template travelrequest001 bound. Its field-level classification — in particular which fields are searchable and therefore held in plaintext in submissions.searchable_values — is settled and assessed elsewhere. The approval workflow consumes that classification; it does not set it.

The twenty-four other forms in the estate. They share the schema, the RLS posture and the routing worker, so facts A, E and F in §4 apply to them equally. They have no approval workflow, and nothing in this assessment changes their behaviour.

Okta configuration and application provisioning. The dedicated forms application (0oazce9jd5inTjtrn417, cut over 2026-08-05) is administered outside this repository. Its group claims, redirect URI registration and lifecycle are the identity provider's assessment, not this one. This scope depends on Okta correctly asserting an identity; it does not assess whether it does.

HappyFox. The ticketing system is a downstream consumer reached by email ingest. Fact F states the consequence for this workflow.

The MAPLE Postfix relay and the Gmail path beyond it. Notification mail is handed to localhost:25 and leaves the assessment boundary there. Fact E states the consequence.

Reporting (R1–R4). Not yet built. Approval-state reporting will need its own scope once designed, because it reads across submissions and approvers and therefore has a disclosure profile unlike any of the four endpoints above.


4. Declared facts

Each of the following is true of the system as it stands. They are stated here as facts with their consequences, not as findings, because they are known, deliberate where noted, and in most cases pre-existing and estate-wide.

A. Row-level security is not a control layer here

relforcerowsecurity is f on every table in the forms schema, including the four added by migration 014. In PostgreSQL, that means the table owner bypasses row-level security entirely. The owner of every table in public is maple-agent@gpus-infra.iam — which is also the identity the 2.5(c) routing worker runs under.

The policies created by 002_rls.sql and by 014 are scoped TO forms_app. They constrain the application role and nothing else.

Consequence: application-layer authorization is the sole access control on all four endpoints in §2. RLS provides defence in depth for the forms_app path and provides nothing against the owner. An assessment that credits RLS as a second layer for these endpoints would be crediting a control that is not engaged. This condition is estate-wide, predates the approval workflow, and is not introduced by it — the four new tables were deliberately built to match the existing posture rather than diverge from it.

B. Two documented exceptions to the no-read-after-submit policy

forms-api-contract.md:271 states that submitters cannot list, view, edit or delete submissions after submitting — fire-and-forget. That rule is load-bearing and is referenced in code at routes_phase2.py:559-561, where a previous design decision was scoped out specifically because relaxing the rule would have been an authorization change.

This workflow introduces exactly two exceptions:

  1. An approver may read a submission on which they hold a DISPATCHED step (§2.1). State-gated, on the step's status and the round's.
  2. A submitter may read their own submission (§2.7). Not state-gated — see the revision below.

Consequence: the policy otherwise stands unchanged, and these two are the complete set. Any third read path discovered during assessment is a defect, not an undocumented feature.

B-revised, 2026-08-19 — exception 2 is no longer state-gated

This section previously read that exception 2 was "a submitter may read their own submission while its approval record is in RETURNED_FOR_REVISION", and concluded that "both exceptions are state-gated". That gate was designed and then deliberately dropped when the endpoint was built as §2.7. The change is recorded here rather than left for an assessor to find as a discrepancy between this document and the code.

What changed: approval_access.resolve_submitter_access compares submissions.submitter_email against the token email and stops. There is no state condition. A submitter reads their own request in any state — in approval, approved, returned, declined, blocked.

Why. The state gate was protecting the wrong thing. The no-read-after- submit policy exists to stop submitters browsing other people's submissions and editing their own after the fact; a read of one's own record does neither. Gating it on RETURNED_FOR_REVISION meant the only person who could not find out where a request had got to was the person who submitted it — which is the condition that produced the operational failure this endpoint was built to end (a submitter waiting on a chain stalled at step 2, with no way to see it and no notification path). The gate bought no confidentiality and cost the subject of the record all visibility of it.

Consequence for the assessment. Exception 1 remains state-gated and a state machine bug there is still an access control bug. Exception 2 is now gated on identity alone, which makes it a simpler control with a smaller failure surface — one string comparison, no dependence on the state machine — and moves its risk entirely onto the correctness of that comparison. Assess it there.

This is the portal's second decrypt path. §2.1 was the first. Both are audited (submission_viewed + decrypt_success), both log every call granted or denied, and both return one uniform 403 for every authorization failure. The disclosure question is materially lighter here — the caller authored every value — but the key-handling question is identical.

Notification email contains https://forms.greenpeace.us/approvals/<submission_id>. There is no token, no query parameter and no secret in that URL. The submission id is an opaque UUID that identifies the record; it does not authorize access to it.

The page is gated by Okta (application 0oazce9jd5inTjtrn417). A forwarded link presents the recipient with an authentication challenge, and on authenticating as someone other than the assigned approver, fails the identity comparison described in §2.1.

This is structural, not compensating. The link cannot be redeemed by possession because possession confers nothing; the authorization decision is made server-side against the caller's asserted identity every time.

This is stated explicitly because an approval link arriving by email reads as a finding on sight, and because the estate contains a genuine counter-example that invites the comparison: the signed GCS attachment URLs appended by routing_worker.py:472-476 are bearer capabilities, where the URL is the credential and possession is authorization. The approval link is a different mechanism with a different property, and the distinction should be recorded before it is mistaken.

D. Snapshot authorization does not revoke

Authorization for §2.1 and §2.2 compares against approval_steps.resolved_okta_email, frozen at dispatch. Removing or deactivating a member in approval_role_members does not revoke access to a step that has already been dispatched to them.

Precisely, the column has two write points and then freezes: approval_persist.insert_round_and_steps writes it for every step when the chain is persisted, approval_dispatch.write_snapshot_dispatched re-writes it for the dispatched step from membership as it stands at that moment, and nothing rewrites it afterwards. The freeze is therefore per step, not per chain: a membership change lands on steps not yet dispatched and misses the one that is. It also means a PENDING step already carries an address, so the address alone can never be the authorization test — status is the other half, and approval_access.resolve_access_core requires both.

This is deliberate. The alternative — authorizing against live membership — silently strands every in-flight approval the moment a role changes, and does so without any signal that it has happened. Stranded approvals are invisible; a revoked one is not.

Consequence: revocation is an administrative re-dispatch, which voids the outstanding step and issues a new one, and is a recorded event. The assessment should evaluate whether that path exists and is auditable, rather than evaluating the snapshot itself as a defect.

E. Notification delivery is not observable

approval_steps.notification_sent_at records that smtplib returned without raising after handing a message to localhost:25 on MAPLE. It means the local relay accepted the message. It does not mean the message was delivered, and it does not mean it was read.

Bounces, rejections downstream of the relay, and silent drops are not detectable from the portal. There is no delivery receipt path back into the database.

Consequence: a stamped notification_sent_at is evidence of handoff, not of notification. Any control that depends on an approver having been told — an escalation timer, a stale-approval alert — must not be built on this column alone. This is the same class of gap VLN-011 documents at §11.1 for HappyFox ticket ids, and the same reasoning applies: absence of an error is not evidence of delivery.

F. The portal never learns the HappyFox ticket number

On the email-ingest path, submissions.happyfox_ticket_id remains NULL permanently. HappyFox mints a ticket number after ingest, in its own system, and never reports it back down the SMTP path. No database query can answer whether a given submission opened a ticket.

Consequence: the portal's own record id is the durable identifier for a travel request and for its approval chain. Cross-referencing to a HappyFox ticket is a manual, out-of-band operation. Any assessment finding, audit trail or user-facing reference must key on the submission UUID.

G. Approval decisions carry no immutability constraint

approval_steps holds the decision record — decided_by_email, decided_at, comment and status. The table carries no immutability constraint of any kind, and forms_app holds UPDATE on it.

audit_log is the deliberate contrast. It is protected twice: 001_init.sql:293 revokes UPDATE, DELETE from PUBLIC, and 002_rls.sql:74 revokes them again from forms_app, forms_admin, forms_viewer and forms_readonly. 014_approval_workflow.sql grants SELECT, INSERT, UPDATE on approval_steps to forms_app and issues no corresponding revoke; neither does 015_approval_steps_parent_fk.sql. No REVOKE statement naming approval_steps exists anywhere in forms-backend/schema/.

Verified by live grant read against gpus_forms on 2026-08-18, via information_schema.table_privileges and has_table_privilege():

Table forms_app privileges UPDATE DELETE
approval_steps SELECT, INSERT, UPDATE t f
audit_log SELECT, INSERT f f

Two points of precision the migration text alone would obscure. The asymmetry is on UPDATE specifically — an approval decision can be rewritten, not erased. And DELETE is absent from both tables for different reasons: on audit_log by explicit revoke, on approval_steps merely because it was never granted. The protections are not equivalent even where the effective privilege matches today.

Consequence: §2.2 asserts non-repudiation as the endpoint's product, and that property does not currently rest on the storage layer. It rests on application discipline — the decision endpoint being the only code that writes these columns — and on the access log that the approval-view path will produce. Any identity holding forms_app can alter a recorded approval decision after the fact, and the row itself will carry no evidence that it was altered. The application role is the identity the Cloud Run backend runs as, so this is not a hypothetical privilege held only by an operator.

This is unresolved, and remediation is undecided and out of scope for this document. It is worth recording why the obvious fix is not obvious: approval_steps has legitimate post-decision writes. notification_sent_at is stamped in a separate transaction after dispatched_at, by design, so that a send failure remains visible (fact E); and administrative re-dispatch, which is how revocation happens at all (fact D), writes to rows that already carry a status. A blanket REVOKE UPDATE would break both. The design question is which columns are append-only and which remain mutable, and that question has not been answered.


5. Applicable ASVS chapters

The following chapters are named as applicable, with the reason each applies to this workflow specifically. This section does not assess them. Verification requirement selection and evaluation belong to the assessment document that follows this one.

V1 — Architecture, Design and Threat Modelling. Applies because three of four endpoints do not exist. This is the chapter that governs whether a security contract was stated before implementation, and this document is the artefact V1 asks for. Facts A and D in §4 are architectural decisions with security consequences and belong to V1's evidence.

V3 — Session Management. Applies because authorization for every endpoint derives from an Okta OIDC session, and because fact C makes the session the only thing standing between a forwarded link and a submission's full contents. Token lifetime, storage and renewal are properties this workflow inherits rather than sets, but it inherits them into a higher-consequence context than any prior portal surface.

V4 — Access Control. The centre of gravity for this assessment. Fact A establishes that application-layer authorization is the sole control; §2.1 establishes that it is snapshot-based; fact B establishes that two of the controls are state-gated rather than role-gated. V4 is where the interaction of those three is evaluated, including the horizontal-access question that §2.1 raises for the first time in this portal: one authenticated user reading another's data.

V7 — Error Handling and Logging. Applies because §2.2's product is non-repudiation. The decision record must be complete, attributable and tamper-evident, and audit_log already carries REVOKE UPDATE, DELETE for every application role — though fact G records that approval_steps, where the decision itself lives, carries no such protection. Fact E belongs here too: a log field that records less than it appears to is a logging defect regardless of the transport underneath.

V8 — Data Protection. Applies because §2.1 and §2.3 decrypt. The portal's AES-256-GCM envelope with per-submission KMS-wrapped keys is the mechanism, and the questions V8 raises are about disclosure minimisation rather than cryptography: which fields an approver needs, whether all thirteen are justified, and how the plaintext mirror in submissions.searchable_values interacts with a decrypt path. The notification email's deliberately minimal body — which omits traveller name and destination precisely because they are not available without a decrypt — is a V8-relevant design decision already taken.

V13 — API and Web Service. Applies because all four are JSON APIs consumed by a single-page application and authorized by bearer token. The chapter covers the transport-level properties the other five depend on: verification of the token, its audience and issuer, and consistent enforcement across every route rather than on the ones that were remembered.


6. What must exist before assessment can proceed

The assessment cannot begin against three endpoints that have not been written. This scope is publishable now and useful now — it constrains the build — but the sequence is:

  1. This scope statement, agreed.
  2. §2.1–§2.3 implemented against it. §2.4 designed, then implemented or formally deferred.
  3. The approval workflow wired into a request path; today nothing is.
  4. Assessment against selected ASVS L2 verification requirements.

Facts A through G do not change at any step and should be carried forward verbatim into the assessment document.


7. References