feat: authorize and enable writes to data connections#162
Conversation
Alignment with the Role direction (#126 / ADR-004, ADR-005, ADR-006)Cross-checked this PR against ADR-004 (inbound auth / Roles), ADR-005 (authorization model) and ADR-006 (outbound storage). It's a forward-compatible increment toward that target, scoped to the outbound write path + the request-time authorization gate — complementary to #126, which builds the inbound Role system. Outbound writes ↔ ADR-006. ADR-006's preferred model is OIDC token issuance: the proxy mints short-lived JWTs and exchanges them at the cloud's STS, no stored secrets. That's exactly what this PR wires ( Authorization ↔ ADR-005. ADR-005 defines effective permission as Role ceiling (from token) ∩ account permissions (from API), per-request, with prefix enforcement. This PR implements the account-permission half — the The batch-delete Re: "does the Out of scope here (owned by #126): the inbound |
Writes were short-circuited with a 405 and the registry only authorized reads. Remove the short-circuit so PUT/POST/DELETE flow through the gateway (where the backend-auth middleware signs them), and add a caller-side authorization layer: - `authz`: a wasm-free, natively-tested module. `is_write_action` classifies the operation; `authorize_write` permits a write only when the connection is not read-only, the connection can sign (an S3 web-identity role), and the caller holds the product's `write` permission. Anonymous callers are rejected in the registry. - registry: `get_bucket` threads whether the op is a write into `resolve_product`, which runs the gate after the subject-scoped product and connection fetches. `DataConnection` gains `read_only` (fail-closed). `authorize_key` is overridden so batch-delete keys are permitted (product-level authz already ran in `get_bucket`). - cache: `get_or_fetch_permissions` fetches the caller's product permissions (60s TTL). - CORS advertises PUT/POST/DELETE. Tracks the multistore data-edit-operations branch, which adds batch delete and the `BucketRegistry::authorize_key` hook (forwarded by `MappedRegistry`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90baf26 to
03dca48
Compare
|
Claude finished @alukach's task in 2m 10s —— View job ✅ No blocking issues — safe to merge.
|
…ites Check the connection-level write preconditions (not read-only, signable) before the per-caller permissions API call, so a write to a read-only or unsignable connection is denied without the round-trip. Splits the gate into two pure predicates (connection_accepts_writes, permits_write). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
🚀 Latest commit deployed to https://source-data-proxy-pr-162.source-coop.workers.dev
|
Drop the connection_accepts_writes / permits_write wrappers (one-line boolean / .any checks) and inline them in resolve_product. Make DataConnection.read_only a required field instead of a fail-closed default helper. authz keeps only is_write_action (the one classification with branching logic worth a native test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The proxy no longer blanket-405s writes; they route through the gateway and are authorized there. Assert the new contract: an unauthenticated write to a real product is denied with 403 at the gate (before any backend call), instead of the obsolete 405. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defense-in-depth from auto-review: return is_write_action(action) instead of a blanket true. No behavior change for batch delete (DeleteObject is a write), but it never blanket-allows a read should multistore ever call authorize_key on a non-write path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — dispositions on the three findings: 1. 2. 3. PATCH no longer 405'd at the edge — conscious decision to leave as-is. PATCH isn't an S3 verb, so it's deliberately absent from the CORS allow-list, and a direct PATCH reaching the gateway gets a 4xx there (no S3 op maps to it). No client sends PATCH to an S3 endpoint; not worth a special-case at the edge. |
multistore 0.6.0 ships the data-edit-operations features (developmentseed/ multistore#88: batch delete, CopyObject rejection, wider write headers, authorize_key), so repoint the multistore* git deps from the moving worktree-data-edit-ops-plan branch to the immutable v0.6.0 tag. 0.6.0 also renames RoleConfig.required_audience -> required_audiences (Vec); StsCredentialRegistry::new now maps its Option<String> through into_iter() (None -> [] no restriction, Some(x) -> [x]), preserving behavior. cf-workers 0.6.0 isn't on crates.io yet, so all crates stay git-pinned to the tag (single-sourced); switch to crates.io version deps once it publishes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pendency Addresses auto-review notes: document why DATA_CONNECTION_CACHE_SECS is longer than the permission TTL despite gating writes (read-path cost; the urgent revocation case rides the 60s permission TTL), and that authorize_key correctness depends on a prior successful get_bucket for the same name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — addressed all four in
Also note: the multistore dep is now repointed to the published 0.6.0 release ( |
All five multistore crates (including multistore-cf-workers) publish 0.6.0 on crates.io, so the git-tag pin and its single-sourcing rationale were unnecessary. Plain version deps resolve a single multistore 0.6.0 from the registry; no git dependency remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-writes # Conflicts: # Cargo.lock # Cargo.toml # src/sts.rs
The source.coop API emits a lowercase "write" today, but comparing with eq_ignore_ascii_case avoids silently denying a legitimately-authorized writer if the casing ever drifts. Strictly fail-safe: only a real write grant matches either way. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wrangler.preview.toml had no [vars] block, so AUTH_AUDIENCE was unset and
/.sts fail-closed with 501 ("STS token exchange is not configured"). Point
preview at the staging Ory issuer and accept the staging frontend + CLI
client_ids so the token exchange works on preview branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preview proxies call the staging API and AWS IAM, which trust only issuer https://data.staging.source.coop + kid data-proxy-1 and fetch the JWKS from that host. Signing the on-behalf-of API token / AWS federation assertion as the per-PR workers.dev hostname (kid source-proxy-1) fails JWKS verification (ERR_JWKS_NO_MATCHING_KEY), so authenticated calls fall back to anonymous and 403 on restricted products. Sign as the canonical staging identity instead. Requires the `preview` GitHub environment's OIDC_PROVIDER_KEY to be the staging signing key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every preview var is now static (the OIDC issuer no longer templates the per-PR hostname), so consolidate LOG_LEVEL, SOURCE_API_URL and OIDC_PROVIDER_* into wrangler.preview.toml [vars] beside AUTH_*, and drop the now-empty var_overrides. AUTH_ISSUER was duplicated across both; de-duped. preview.yml keeps only the per-PR dynamic bindings (worker_name, PUBLIC_LOG_STREAM). Behavior-preserving: the same vars reach the worker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch multistore* to developmentseed/multistore#92 (fix/decode-aws-chunked-writes) so preview/staging pick up the aws-chunked body decode. Without it, the modern aws-cli's default trailer-checksum uploads are stored as raw chunk framing — corrupted writes. Revert this [patch.crates-io] block and bump the deps to a crates.io release once the fix ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-resolve the multistore git patch to the rewritten developmentseed/multistore#92, which now stream-re-signs aws-chunked uploads (zero-copy) instead of buffering+decoding — relevant on the Workers memory ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Enables authenticated writes through the proxy and gates them so only callers with write permission can edit. Builds on #147 (per-connection backend OIDC federation, which added the signing path but left writes 405'd and unauthorized) and on multistore 0.6.0 (batch delete,
CopyObjectrejection, wider write headers, and theBucketRegistry::authorize_keyhook).How
Enable writes. Remove the edge 405 short-circuit so
PUT/POST/DELETEflow through the gateway, where #147's backend-auth middleware signs them. CORS now advertises the write methods.Authorize writes. The registry gates every write in
resolve_product, after the subject-scoped product + connection fetches (so a caller who can't even see the resource is already 404/403'd). A write is denied unless all hold:s3_web_identity_role);writepermission (Source API/permissions).The connection-level checks run before the permissions fetch, so a write the connection can't accept skips the API call.
is_write_action— in the wasm-free, natively-testedauthzmodule — classifies the operation (a denylist over reads, fail-safe by direction); the rest of the gate is inline in the registry.Where it runs.
get_bucketthreads whether the op is a write intoresolve_product.DataConnectiongains a requiredread_onlyfield (absent fails the fetch rather than defaulting to writable).get_or_fetch_permissionsfetches the caller's product permissions (60s TTL, per-subject cache key).Batch delete.
SourceCoopRegistry::authorize_keyis overridden to permit keys (product-level authz already ran inget_bucket); multistore'sMappedRegistryforwardsauthorize_keyso the override is honored (it was previously shadowed by the deny-all default).Authorization model
Signing is a property of the connection; authorization is a property of the caller + operation — orthogonal, so a public product can sit on a private bucket the proxy signs into. This is the account-permission half of ADR-005 (
/permissions), with the Role-ceiling half deferred to the Role system (#126);authorize_keyis the seam where per-prefix ceiling enforcement will land.Dependency
multistore*crates are pinned to the published 0.6.0 release on crates.io (batch delete +authorize_key/MappedRegistryforward shipped there). No git/branch deps remain.Test plan
cargo check --target wasm32-unknown-unknown— cleancargo clippy --target wasm32-unknown-unknown— cleancargo fmt --check— cleancargo test—authz(2),backend_auth(10),routing(12),pagination(13)test_anonymous_write_deniedconfirms an anonymous write to a public product is denied (403) at the gate; full CI greenDeployment prerequisite
Writes to a connection require it to be configured with
authentication.type = s3_web_identity_roleand arole_arnwhose IAM trust accepts the proxy's OIDC provider (aud = sts.amazonaws.com,sub = scv1:conn:{id}). Connections without a role keep working read-only/anonymous.🤖 Generated with Claude Code