# Direct-upload deployment contract

Browser-direct uploads use a SigV4 `PutObject` URL. The signature binds the
exact `Content-Length`, `Content-Type`, `x-amz-checksum-sha256`, and
`If-None-Match: *`. S3 accepts exactly one successful create for an object key;
later or concurrent replays return HTTP 412 and do not create a new version.

## Bucket CORS prerequisite

The upload bucket CORS rule for the Helix web origin must allow:

- method `PUT`;
- request headers `Content-Type`, `If-None-Match`, and
  `x-amz-checksum-sha256`;
- the exact production Helix origin (and explicit local origins only where
  development uploads are intentionally supported).

Do not replace the origin allowlist with `*`. `Content-Length` is emitted by the
browser for the `File` body and is a forbidden script-controlled header, so it
is signed and enforced by S3 but is not expected in the CORS preflight header
list.

Before deploying an upload-contract change, inspect the current bucket CORS
configuration and exercise a real browser upload from the deployed origin.
Confirm that the preflight succeeds, the first PUT returns 200, an identical
second PUT to the same signed URL returns 412, and `HeadObject` with checksum
mode reports the declared size, MIME, and SHA-256. A 412 after an ambiguous
network response is safe to continue to business registration: registration
performs the authoritative HEAD verification before claiming the grant.

## Defense in depth

Where bucket-policy ownership permits, deny non-conditional `PutObject` calls
for the browser-upload purpose prefixes using the S3 `s3:if-none-match`
condition key. Keep release-artifact and other service-owned prefixes outside
that rule unless their writers also implement conditional-create semantics.

## Production EC2 role contract

The production host uses the uploads bucket through the inline IAM policy named
`HelixProdUploadsAccess`. Terraform owns that exact live name through the EC2
module. State-bucket `deploy-assets/*` access remains in a different policy and
must not be widened when application storage changes.

> 🔴 **Terraform here is plan-only — merging a prefix does NOT grant it.** CI runs
> `terraform plan`; nothing applies. Until someone pushes the rendered document to
> the live inline policy, a new purpose's presigned PUT is signed happily and then
> 403s in the browser (`asset_disposal_evidence` shipped in #3401, was written back
> to Terraform in #3402, and stayed dead in production until the live policy was
> pushed on 2026-09-03). Shipping a new purpose therefore has a **fourth** step
> after code + Terraform + tests:
>
> ```bash
> aws iam get-role-policy --role-name helix-prod-ec2-role \
>   --policy-name HelixProdUploadsAccess --query PolicyDocument --output json > /tmp/uploads.backup.json
> # render the document from infra/aws/modules/ec2/main.tf's four statements, then:
> aws iam put-role-policy --role-name helix-prod-ec2-role \
>   --policy-name HelixProdUploadsAccess --policy-document file:///tmp/uploads.new.json
> ```
>
> Verify with the real signer, not with `simulate-principal-policy` alone — sign a
> probe PUT **inside the prod backend container** (`docker exec … /app/.venv/bin/python`,
> whose credentials are the EC2 role) and assert HTTP 200, then delete the probe object
> by version-id (the bucket is versioned; deleting without one leaves a delete marker
> and the object).
>
> 🔴 **Do not send the probe PUT with `urllib.request`.** It sets
> `Content-Type: application/x-www-form-urlencoded` on any request that carries a body,
> the presigned signature covers that header, and S3 answers **403** — which reads
> exactly like the AccessDenied you are testing for. Read the error CODE out of the
> response body (`<Code>SignatureDoesNotMatch</Code>` vs `<Code>AccessDenied</Code>`)
> and send the probe the way a browser does, with `http.client` and no headers beyond
> `Content-Length`. Run the same probe against a prefix that is already live as a
> control: if both fail identically, the fault is the probe, not the policy. (Cost a
> false "the IAM push did not work" during the `awareness_material` rollout on
> 2026-09-03.)

**Live-policy pushes so far** — the list exists because "merged" and "granted" are
different events, and only this column records the second one:

| Prefix | Terraform | Live policy pushed |
| --- | --- | --- |
| `asset_disposal_evidence/*` | #3402 | 2026-09-03 |
| `awareness_material/*` | #3418 | 2026-09-03 — verified by a browser-shaped presigned PUT from inside `compose-backend-1` returning 200, probe deleted by version-id, residue re-listed as 0 |

The uploads policy has only the following object access:

| Prefix | Object actions | Production paths |
| --- | --- | --- |
| `inventory_evidence/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Inventory evidence presigned upload, HEAD registration, download, and orphan cleanup |
| `feed_post_image/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Feed-image presigned upload, HEAD registration, download, and orphan cleanup |
| `feed_announcement/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Announcement-image presigned upload, HEAD registration, download, and orphan cleanup |
| `approval_supplement/*` | `GetObject`, `PutObject` | Server-side supplement copy and authorized presigned download |
| `evidence_documents/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Auditor evidence-document upload, registration, download, and orphan cleanup |
| `vendor_assessment_evidence/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Vendor-assessment evidence upload, HEAD registration, download, and orphan cleanup |
| `self_check_evidence/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Self-check evidence upload, HEAD registration, download, and orphan cleanup |
| `inbound_questionnaire/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Inbound source upload/read, generated-version server-side write/read, and orphan cleanup |
| `bounty_attachment/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` (no live key matches — bounty keys are `bounty_attachment/<report_no>/<sha256>`) | White-hat report attachment upload and authorized presigned download |
| `asset_disposal_evidence/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Admin disposal-evidence presigned upload, HEAD registration, download, and orphan cleanup |
| `awareness_material/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | Security-awareness material upload (security desk only — see the write gate below), workforce-wide presigned read, and orphan cleanup |
| `ai_labs_image/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | AI Labs image attachment / generation input: presigned upload, server-side read for inlining into the vendor request, and deletion when the employee deletes the conversation or job |
| `ai_labs_document/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | AI Labs document attachment: presigned upload, server-side text extraction, and deletion on employee delete |
| `ai_labs_audio/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | AI Labs transcription input: presigned upload, server-side read, and deletion on employee delete |
| `ai_labs_output/*` | `GetObject`, `PutObject`; cleanup `DeleteObject` only on `20??/*` | **Not an upload purpose.** The generation executor re-homes the vendor's artifact here with `put_object_bytes` (the vendor's own link expires ~24h out), `presign_get` signs the employee's download, and a delete removes it. Absent from `ALLOWED_PURPOSES`, so `test_every_upload_purpose_has_an_iam_prefix` cannot catch a missing grant here — every generation would succeed at the vendor and then fail to store, in production only |
| `cli-dist/*` | `GetObject` | CLI manifest read and presigned artifact download |
| `plugin-dist/*` | `GetObject` | Plugin manifest read and presigned artifact download |

There is no bucket-wide object ARN. `DeleteObject` is limited to the fourteen
namespaces the platform deletes from and their date-partitioned `20??/*` keys;
notably it cannot delete approval supplements or inbound generated exports.

### 上传凭证有效期按 purpose,AI 实验室是 4 小时 (2026-09-04)

`storage_uploads.GRANT_TTL_BY_PURPOSE`。默认仍是 600 秒,只有三个 `ai_labs_*` 是 `4 * 60 * 60`。

原因:其它每一个消费方都是「presign → 提交表单」,间隔一次点击;AI 实验室**选文件那一刻就上传**,而认领发生在**发送**时,中间是员工写这段话花的时间。10 分钟的凭证意味着附一个 PDF 然后想 12 分钟就换来一个 409,而且前端在请求前已经清了草稿和附件 —— 员工两样都丢。

🔴 **调 `storage.orphan_upload_cleanup` 的 `retention_hours` 时要知道**:那个 job 原来只按 `created_at` 挑行、**从不读 `expires_at`**,安全只是因为过去所有 TTL 都短于它的一小时下限。4 小时凭证把这个顺序颠倒了,所以闸门已改成同时要求凭证已过期。也就是说**现在把 `retention_hours` 调到 1–3 小时是安全的**;在改之前它会把员工还能用的附件删掉,而员工点发送时看到的是一个 `UPLOAD_NOT_FOUND`,文件却还挂在输入框上。

### AI 实验室三个 purpose 的键**不带文件名** (2026-09-03)

`storage_uploads.OPAQUE_NAME_PURPOSES` 覆盖 `ai_labs_image` /
`ai_labs_document` / `ai_labs_audio`:键的最后一段是随机名 + 扩展名,不是
`<rand>-<文件名>`。

原因不是美观。键会被抄进 `storage_upload_grants` 台账行、抄进
`storage.upload.authorize` 审计行(**全公司可读**:`/audit/logs` 与它的 CSV
导出),并且**活过员工自己在 AI 实验室里的硬删**。这个界面对员工的承诺是
「没人看你写的东西」,而 `离职谈话录音.m4a` 是内容而不是元数据。按角色过滤读者
改不了「键已经被抄到三个地方」这件事,所以修在生成键的那一层。

排查提示:这三个前缀下的对象名对人类没有可读性,**这是设计而不是 bug**。
要认出一个对象属于谁,看键里的 `person_id` 段;展示用的文件名只存在员工自己的
`ai_labs_messages.attachments` 行上,删掉会话就一起没了。其余每个 purpose 都
照旧保留文件名 —— 管理员查盘点纠纷时要看见 `receipt-2026-03.pdf`。

Multipart-specific list/abort actions and KMS actions are intentionally absent. Production currently
uses S3-managed AES256 default encryption on this bucket; if the bucket moves
to SSE-KMS, stop and review the checksum-enabled HEAD path before rollout
because AWS documents additional KMS permissions for that mode.

The application has no multipart call path. AWS nevertheless maps multipart
creation, part upload, and completion to the same required `s3:PutObject`
action as the single-object PUT that Helix needs. This identity policy can omit
multipart-specific list/abort permissions, but it cannot claim that every
multipart request is denied while retaining `PutObject`. A hard multipart
prohibition would need a separately reviewed bucket-policy/request-shape
control.

### HeadObject missing-object contract

`storage.stat_object` treats `404`/`NoSuchKey` as an absent upload and lets the
business endpoint reject registration cleanly. AWS S3 deliberately returns
`403` rather than `404` for a missing object when the caller lacks
`s3:ListBucket`. The policy therefore grants `ListBucket` on the bucket ARN with
`StringLike s3:prefix` restricted to the same twelve prefixes above. It does not
grant general listing. See the AWS
[HeadObject permission contract](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html#API_HeadObject_RequestSyntax).

### Delete and rollout boundary

The only current `DeleteObject` caller is `storage.orphan_upload_cleanup`, which
removes abandoned direct uploads after a HEAD/ETag check. Its permission is a
separate statement restricted to the nine `StorageUploadGrant` namespaces and
their `<purpose>/20??/*` date shape. `approval_supplement` has no grant lifecycle
and therefore receives no delete permission; inbound generated exports use a
questionnaire-id path rather than a year and are outside the delete resource.

Before the IAM cutover, use real canaries without printing signed URLs:

1. exercise upload, checksum HEAD registration, and download for every
   read/write prefix;
2. HEAD a unique nonexistent key under every allowed prefix and require the
   application to observe `404`/absent rather than `403`/AccessDenied;
3. load both CLI and plugin manifests and mint one download URL from each;
4. confirm an unrelated prefix, approval-supplement deletion, inbound export
   deletion, multipart-specific list/abort, and KMS calls remain denied;
   separately confirm date-shaped cleanup deletion is allowed and no application
   path invokes multipart creation/upload/completion;
5. require the Terraform plan coordinated by issue #2515 to update the exact existing
   inline policy without dropping it.
