Forms Portal — API Contract (Phase 2)¶
Classification: CONFIDENTIAL — Internal Use Only Document:
architecture/forms-api-contract.md· v1.3 · 2026-04-24 · GPUS-IT Service:gpus-forms-backend· Host:forms.greenpeace.usStatus: DRAFT — endpoints to be implemented in Phase 2
Purpose¶
Defines the HTTP contract between gpus-forms-frontend (React SPA) and gpus-forms-backend (Flask on Cloud Run). The frontend implementation must not diverge from this document without a corresponding contract update.
Workflow¶
Submitter logs in (Okta) → picks a form → fills it out → submits
↓
┌───────────────┴───────────────┐
↓ ↓
HappyFox API call Email to department group
(creates ticket) (full submission contents inline)
↓ ↓
Ticket ID + URL returned Department works the ticket
↓
Submitter sees confirmation
with ticket number
Submitters never view a queue or revisit submissions — fire-and-forget. Admin (2 people total, you + one backup) manages forms and audits submissions.
Roles¶
Only two roles exist:
| Role | Source | Capabilities |
|---|---|---|
admin |
Email match against env var FORMS_ADMIN_EMAILS (initial: rajesh.chhetry@greenpeace.us + 1 backup) |
Create / edit / delete forms; view all submissions; view audit log; retry failed HappyFox tickets / emails |
submitter |
Membership in Okta group apps-us-internal_portal_users (verified via groups claim in ID token) |
Pick a form, fill it, submit it |
No viewer, no routing_recipient. Departments receive notifications (HappyFox tickets + email + weekly reports) — they are not portal users.
When (if) Conan provisions apps-us-forms_admin, the admin check swaps from email-allowlist to group membership. Migration checklist at the bottom.
Auth model¶
This tenant does not have Okta API Access Management licensed, so there are no custom or default authorization servers available. The backend validates the OIDC ID token issued by the SPA's OIDC app.
- Every request below (except
/health) requires a valid Okta-issued ID token in theAuthorization: Bearer <token>header. - Token issuer (
issclaim):https://greenpeaceeu.okta.com - Token audience (
audclaim):0oavvg1y33wTWFsmP417(the Okta OIDC app clientId) - JWKS endpoint (org-level, no
/oauth2/default/):https://greenpeaceeu.okta.com/oauth2/v1/keys - Backend validates: signature (via JWKS, cached 1h),
iss,aud,exp,nbf, and thegroupsclaim. - Email comes from the
emailclaim; name fromname; subject fromsub.
Caveat: Validating ID tokens at an API is a recognized OAuth anti-pattern in tenants that have API Access Management. With our tenant it's the only option Okta makes available; documented in Okta's own guidance. Risk is mitigated by short token lifetime (1h default), full claim validation, and TLS-only transport.
Token lifetime: Okta default is 1h for ID tokens. Frontend uses @okta/okta-auth-js silent renewal (hidden iframe) to refresh before expiry. If renewal fails (third-party cookies blocked, session timeout, etc.) the SPA prompts re-login.
Group claim configuration is on the OIDC app itself, not on an authz server. See "Okta configuration prerequisites" below — this is a manual setup step in Okta admin that must be completed before Phase 2 can ship. If the groups claim is absent from issued tokens, the backend returns 403 for everything except admin-allowlisted emails.
- Unauthenticated requests:
401 Unauthorizedwith JSON{"error": "invalid_token", "detail": "<reason>"}. - Authenticated but unauthorized-for-this-endpoint:
403 Forbiddenwith{"error": "forbidden", "required_role": "admin"}.
CORS: Access-Control-Allow-Origin set to https://forms.greenpeace.us only. No wildcard.
Rate limiting: enforced at Cloud Armor (WAF) — 60 req/min per IP for /api/*, 10 req/min for /api/submissions POST. Backend does not re-implement.
Okta configuration prerequisites¶
Before Phase 2 backend deploys, verify the OIDC app has the groups claim wired into the ID token:
- Okta Admin Console → Applications → Applications → "GPUS Internal Portals"
- Sign On tab
- Scroll to "OpenID Connect ID Token" section
- Confirm or set:
- Issuer:
Okta URL(i.e.https://greenpeaceeu.okta.com— the org-level issuer, not an authz server URL) - Audience:
0oavvg1y33wTWFsmP417(clientId — auto-populated) - Groups claim type:
Filter - Groups claim filter: name =
groups, filter =Matches regex, value =.* - Save.
After save, force a fresh login (sign out + sign back in to the portal) and decode the ID token at jwt.io to confirm the groups claim is present and lists apps-us-internal_portal_users. Don't paste a real production token into jwt.io's website — use a local decoder, or just log it from the browser console once.
If the groups claim isn't being issued, no submitter except those in FORMS_ADMIN_EMAILS will be able to use the portal.
Conventions¶
- All request and response bodies are
application/json; charset=utf-8. - File uploads use
multipart/form-dataonly onPOST /api/submissions/:id/attachments. - Timestamps are ISO-8601 UTC (
2026-04-24T14:30:00Z). - IDs are UUIDv4 strings unless noted.
- Errors always return
{"error": "<machine_code>", "detail": "<human_message>"}. Never surface backend stack traces. - Pagination uses
?limit=N&cursor=<opaque>; responses includenext_cursor(null when exhausted).
Endpoints¶
Health & meta¶
| Method | Path | Auth | Role | Purpose |
|---|---|---|---|---|
| GET | /health |
none | — | Liveness probe |
| GET | /api/me |
JWT | any | Returns the authenticated user's identity + role |
| GET | /api/config |
JWT | any | Returns non-secret runtime config for the SPA |
GET /api/me response:
{
"email": "rajesh.chhetry@greenpeace.us",
"name": "Rajesh Chhetry",
"sub": "00u1a2b3c4d5e6f7g8",
"roles": ["admin", "submitter"],
"okta_groups": ["apps-us-internal_portal_users"]
}
The SPA uses roles, never okta_groups, so the Phase 2.1 swap (email-allowlist → group-based admin) is invisible to the frontend.
GET /api/config response:
{
"max_upload_bytes": 26214400,
"allowed_mime_types": ["application/pdf", "image/png", "image/jpeg", "text/csv"],
"attachment_scan_required": true,
"features": {
"happyfox_integration": false,
"admin_ui": true
}
}
features.happyfox_integration flips to true when Phase 3 ships.
Forms (schema — read)¶
| Method | Path | Auth | Role | Purpose |
|---|---|---|---|---|
| GET | /api/forms |
JWT | any | List all available forms |
| GET | /api/forms/:form_id |
JWT | any | Return full form schema for the submitter to render |
GET /api/forms response:
{
"forms": [
{
"id": "f4e5d6c7-...",
"slug": "hr-offboarding",
"title": "HR Offboarding Request",
"category": "hr",
"description": "Submit when an employee is leaving.",
"updated_at": "2026-04-18T12:00:00Z"
}
]
}
GET /api/forms/:form_id response:
{
"id": "f4e5d6c7-...",
"slug": "hr-offboarding",
"title": "HR Offboarding Request",
"category": "hr",
"description": "Submit when an employee is leaving.",
"fields": [
{
"id": "employee_name",
"label": "Employee name",
"type": "text",
"required": true,
"max_length": 200,
"searchable": false
},
{
"id": "last_day",
"label": "Last day of work",
"type": "date",
"required": true,
"searchable": true
},
{
"id": "reason",
"label": "Reason for departure",
"type": "pulldown",
"required": true,
"pulldown_id": "hr_departure_reasons",
"searchable": true
},
{
"id": "notes",
"label": "Additional notes",
"type": "textarea",
"required": false,
"max_length": 5000,
"searchable": false
},
{
"id": "exit_interview_doc",
"label": "Exit interview document",
"type": "attachment",
"required": false,
"max_count": 3
}
],
"updated_at": "2026-04-18T12:00:00Z"
}
Field types (enum): text · textarea · date · datetime · number · email · pulldown · multiselect · checkbox · attachment.
searchable mirrors the Phase 1 decision — true = plaintext column, false = AES-256-GCM envelope-encrypted. The SPA renders both the same way; the flag exists only for transparency in admin views.
The form schema returned to submitters does not include notification config (department email, HappyFox category) — submitters don't need to know where their submission is routed. Admin endpoints (below) include those fields.
Pulldowns¶
| Method | Path | Auth | Role | Purpose |
|---|---|---|---|---|
| GET | /api/pulldowns/:pulldown_id |
JWT | any | Return options for a named pulldown |
Response:
{
"id": "hr_departure_reasons",
"options": [
{"value": "voluntary", "label": "Voluntary"},
{"value": "involuntary", "label": "Involuntary"},
{"value": "retirement", "label": "Retirement"},
{"value": "other", "label": "Other"}
],
"updated_at": "2026-03-01T09:00:00Z"
}
SPA caches per-pulldown for 5 min using the updated_at as a cache key.
Submissions (submitter)¶
| Method | Path | Auth | Role | Purpose |
|---|---|---|---|---|
| POST | /api/submissions |
JWT | submitter, admin | Create a new submission (data only; attachments in follow-up call) |
| POST | /api/submissions/:id/attachments |
JWT | submitter, admin | Upload one or more attachments to an existing submission |
| POST | /api/submissions/:id/submit |
JWT | submitter, admin | Finalize submission (triggers HappyFox API ticket creation + department email) |
Two-phase submission rationale: creates a draft row first so attachments have a parent to hang off. The /submit finalization call flips status from draft to submitted and is the point at which HappyFox + email fire. This also lets the SPA recover from a dropped connection mid-upload without orphaning attachments.
Submitters cannot list, view, edit, or delete submissions after submitting — fire-and-forget. The HappyFox ticket is the durable record they reference if they need to follow up.
POST /api/submissions request:
{
"form_id": "f4e5d6c7-...",
"fields": {
"employee_name": "Jane Doe",
"last_day": "2026-05-15",
"reason": "voluntary",
"notes": "Moving to another organization."
}
}
Response (201 Created):
{
"id": "a1b2c3d4-...",
"form_id": "f4e5d6c7-...",
"status": "draft",
"created_at": "2026-04-24T14:30:00Z",
"created_by": "rajesh.chhetry@greenpeace.us"
}
POST /api/submissions/:id/attachments request: multipart/form-data with field name files (repeatable). Each file is scanned with ClamAV before GCS write; infected files return 422 Unprocessable Entity with {"error": "malware_detected", "detail": "file <n> failed AV scan"}.
Response (201):
{
"attachments": [
{
"id": "att-1-...",
"filename": "exit_interview.pdf",
"size_bytes": 182341,
"mime_type": "application/pdf",
"scan_status": "clean",
"uploaded_at": "2026-04-24T14:31:00Z"
}
]
}
POST /api/submissions/:id/submit: no body. Response (200):
{
"id": "a1b2c3d4-...",
"status": "submitted",
"submitted_at": "2026-04-24T14:32:00Z",
"happyfox_ticket_id": "HF-12345",
"happyfox_ticket_url": "https://greenpeace.happyfox.com/staff/ticket/12345",
"email_sent_to": "hr-team@greenpeace.us",
"email_status": "sent"
}
In Phase 2, happyfox_ticket_id and happyfox_ticket_url are always null (HappyFox feature-flagged off). Email IS sent in Phase 2 — that path goes through Postfix relay on MAPLE and is independent of HappyFox.
If HappyFox API call fails (Phase 3+), the submission still succeeds and the response is:
{
"id": "a1b2c3d4-...",
"status": "submitted",
"submitted_at": "2026-04-24T14:32:00Z",
"happyfox_ticket_id": null,
"happyfox_ticket_url": null,
"happyfox_error": "upstream_timeout",
"email_sent_to": "hr-team@greenpeace.us",
"email_status": "sent"
}
The submission is queued in a DLQ for retry; admin can replay via POST /api/admin/submissions/:id/retry-happyfox.
If email sending fails (any phase), email_status is "failed" and the submission is queued in a separate email-DLQ. The submission is still considered successful — neither failure blocks the user.
Department notifications (workflow detail)¶
When /submit succeeds, the backend performs two side effects in this order:
1. HappyFox ticket creation (Phase 3+, no-op in Phase 2)
- HTTP
POSTto HappyFox API endpoint, authenticated with credentials from Secret Manager (happyfox-api-key,happyfox-api-id). - Maps form's
notification.happyfox.category_idandnotification.happyfox.priorityfrom the form's admin config. - Ticket subject =
"<form.title> — <submission.summary>"(e.g.,"HR Offboarding Request — Jane Doe · 2026-05-15"). - Ticket body = full submission contents (same template as the email below).
- Attachments uploaded to the ticket via HappyFox attachment API.
- On 2xx: ticket ID + URL stored on submission row.
- On non-2xx or timeout (>10s): submission is queued in DLQ; user response includes
happyfox_error.
2. Department email
- SMTP via Postfix relay on MAPLE (existing infrastructure).
- From:
gpus-it-forms@greenpeace.org(matches existing service-account pattern). - To: form's
notification.email.recipients(a list, e.g.,["hr-team@greenpeace.us"]). - Subject:
"[Forms] <form.title> — <submission.summary>"with[Forms]prefix for filtering. - Body: full submission contents inline, rendered as plain text (with HTML alternative). Field labels followed by submitted values, one field per line. Attachments listed by filename + size; the email links back to the HappyFox ticket if available, otherwise to a one-time-use signed URL for each attachment (24h expiry, audited).
- Encrypted fields are decrypted just-in-time for the email render and not logged.
Email body template (plain text):
Greenpeace USA — Forms Portal
─────────────────────────────────────────
Form: HR Offboarding Request
Submitted: 2026-04-24 14:32 UTC
Submitter: rajesh.chhetry@greenpeace.us
Ticket: HF-12345 (https://greenpeace.happyfox.com/staff/ticket/12345)
─── Submission ──────────────────────────
Employee name: Jane Doe
Last day: 2026-05-15
Reason: Voluntary
Additional notes:
Moving to another organization.
─── Attachments ─────────────────────────
• exit_interview.pdf (178 KB)
https://forms.greenpeace.us/attachments/<signed-url>
─────────────────────────────────────────
This email was generated automatically by the Greenpeace USA Forms Portal.
Reply to this email to add to the HappyFox ticket.
Admin¶
Admin endpoints are gated by path prefix /api/admin/* for unambiguous authorization.
| Method | Path | Auth | Role | Purpose |
|---|---|---|---|---|
| GET | /api/admin/forms |
JWT | admin | List all forms (includes notification config + draft/published state) |
| POST | /api/admin/forms |
JWT | admin | Create a new form |
| GET | /api/admin/forms/:form_id |
JWT | admin | Full form definition (schema + notification + audit metadata) |
| PUT | /api/admin/forms/:form_id |
JWT | admin | Update a form (fields, validation, notification config) |
| DELETE | /api/admin/forms/:form_id |
JWT | admin | Soft-delete a form (sets deleted_at, hides from /api/forms; preserves submission history) |
| GET | /api/admin/submissions |
JWT | admin | List all submissions (paginated, filterable by form_id, status, date range) |
| GET | /api/admin/submissions/:id |
JWT | admin | Single submission with all encrypted fields decrypted, all attachments, full notification history |
| POST | /api/admin/submissions/:id/retry-happyfox |
JWT | admin | (Phase 3+) Retry a failed HappyFox API ticket creation from the DLQ |
| POST | /api/admin/submissions/:id/retry-email |
JWT | admin | Retry a failed department email from the DLQ |
| GET | /api/admin/audit |
JWT | admin | Audit log (append-only, paginated) |
Admin form definition (PUT /api/admin/forms/:form_id) includes a notification block that submitters never see:
{
"id": "f4e5d6c7-...",
"slug": "hr-offboarding",
"title": "HR Offboarding Request",
"category": "hr",
"description": "Submit when an employee is leaving.",
"fields": [ ... ],
"notification": {
"email": {
"recipients": ["hr-team@greenpeace.us"],
"include_attachments": "link"
},
"happyfox": {
"category_id": 14,
"priority": "medium",
"assignee_group_id": 7
}
},
"summary_template": "{employee_name} · {last_day}",
"published": true
}
summary_template uses {field_id} placeholders and only references searchable: true fields (since plaintext is required for the summary line).
Reports (separate from API — informational)¶
Reports are generated by forms-backend/reports/ cron jobs on MAPLE, not by the SPA. Documented here so the contract reflects the full system.
| Report | Cadence | Recipients | Generator | GCS path |
|---|---|---|---|---|
| Forms Activity Weekly (per-department) | Mondays 08:00 ET | Each department group address — only their own forms | weekly_dept_report.py (one PDF per department) |
gs://gpus-infra-backups-wdc/reports/forms-weekly/<YYYY-MM-DD>/<department>.pdf |
| Forms Activity Monthly (aggregate) | 1st of month 08:00 ET | Leadership distribution list | monthly_forms_report.py |
gs://gpus-infra-backups-wdc/reports/forms-monthly/ |
Weekly per-department reports include: submissions count by form, top submitters, average time-to-resolution (from HappyFox once Phase 3 lands), failed notifications. Monthly aggregate is the existing scoped Forms Activity Monthly report — unchanged.
Error codes¶
| HTTP | error code |
When |
|---|---|---|
| 400 | invalid_request |
Malformed body, unknown field, validation failure |
| 401 | invalid_token |
Missing / expired / signature-invalid JWT |
| 403 | forbidden |
Valid JWT, insufficient role |
| 404 | not_found |
Resource does not exist |
| 409 | conflict |
Submission already finalized / attachment limit exceeded |
| 413 | payload_too_large |
Upload exceeds max_upload_bytes |
| 415 | unsupported_media_type |
MIME type not in allowed_mime_types |
| 422 | malware_detected |
ClamAV flagged the upload |
| 429 | rate_limited |
(Surfaced from Cloud Armor — backend may also emit) |
| 500 | internal_error |
Unhandled server fault; details in Cloud Logging, not in response |
| 502 | happyfox_unavailable |
(Phase 3+) HappyFox API call failed; submission still saved, queued for retry |
Out of scope for Phase 2¶
- Submitter view of past submissions (intentionally — fire-and-forget per workflow)
- Admin UI for form schema editing in the SPA (forms authored via YAML in repo for Phase 2; API endpoints exist but UI is Phase 2.2)
- Submission editing after
submit - Search across encrypted fields
- HappyFox API integration (Phase 3 — but response shape is reserved here)
- Department reply-by-email auto-attaching to HappyFox ticket (HappyFox handles this natively when the email comes from the ticket thread; documented for Phase 3)
Phase 2 → Phase 2.1 migration checklist¶
When (if) Conan delivers apps-us-forms_admin:
- Assign the 2 admin users to the group in Okta.
- Update backend env: remove
FORMS_ADMIN_EMAILS, no other config change needed. - Update auth middleware: replace email-allowlist admin check with
'apps-us-forms_admin' in user.okta_groups. - Deploy backend (no frontend change required —
/api/meresponse shape is unchanged). - Verify: a non-admin user gets 403 on
/api/admin/audit; an admin user gets 200. - Update this contract: bump to v1.4, mark Phase 2.1 sections as removed.
Change log¶
| Version | Date | Author | Change |
|---|---|---|---|
| v1.3 | 2026-04-24 | R. Chhetry | Tenant has no API Access Management → no authz servers. Switched to ID-token validation: org-level JWKS endpoint (/oauth2/v1/keys, no /default), ID token (not access token) sent as bearer, audience = clientId, groups claim sourced from OIDC app config (not authz server). Added Okta configuration prerequisites section. |
| v1.2 | 2026-04-24 | R. Chhetry | Simplified to two-role model (admin × 2 + submitter). Dropped viewer + routing_recipient roles. Dropped submitter-facing GET /api/submissions. Added explicit notification block (HappyFox API + department email with full inline body). Added weekly per-department report alongside existing monthly aggregate. Added admin form CRUD endpoints. |
| v1.1 | 2026-04-24 | R. Chhetry | Single existing Okta group + email-allowlist admin; default authorization server with aud=clientId; HappyFox = direct API call (not email). |
| v1.0 | 2026-04-24 | R. Chhetry | Initial contract draft — Phase 2 scope |