Add file storage for resources behind a pluggable object store - #128
Conversation
Files are resources of kind "file" with server-owned storage metadata in file_objects. Bytes live behind a BlobStore interface with a memory store and a feature-gated object_store adapter (local, S3-compatible, GCS, Azure), pinned by a shared conformance suite. REST routes stream uploads and downloads with content sniffing, safe download headers, a tenant quota and signed URLs; a queue fed by triggers deletes unreferenced bytes after a grace period. Storage is off unless ATOM_STORAGE_BACKEND is set. Closes absmach#127
dborovcanin
left a comment
There was a problem hiding this comment.
Reviewed c59fa7e...f519ba4. Three verified findings below.
Validation: all 11 existing file integration tests passed on SQLite; three focused review probes reproduced the authorization and deletion failures. Six storage unit/conformance checks passed (memory, object_store InMemory, local); live S3 was not run. PostgreSQL was inspected but not rerun locally. Rust/API CI is green. The frontend job fails at pnpm audit on the unchanged Next.js dependency, separately from these changes.
…aims durable Existing-file operations use the PDP object decision instead of the tenant/object capability gate, so object-kind, object-type and group policies and scoped-token ceilings apply. File lookups require an active, non-deleted tenant, so public files and signed links stop with their tenant. The deletion worker leases claims and removes a queue row only after its bytes are deleted; an interrupted pass is retried after the lease expires.
dborovcanin
left a comment
There was a problem hiding this comment.
Re-reviewed the update f519ba4..5bcf41b against all three prior findings. All three are verified fixed; posted verification details and resolved their threads. No new blocking findings in the update.
Validation: all 14 file integration tests passed on SQLite, including policy denies, inactive/deleted-tenant downloads, interrupted deletion recovery, and the upload/grace-period race. Six memory/local storage unit and conformance checks passed; cargo fmt --check and database/storage boundary/schema parity checks passed. PostgreSQL changes were inspected but not executed locally; live S3 was not exercised. CI is still in progress. No approval submitted.
arvindh123
left a comment
There was a problem hiding this comment.
Approved for the feature merge at 88c7cac, with the four deferred reliability and observability findings tracked in #130.
The follow-up covers incomplete multipart upload cleanup, returning replacement metadata before commit, failure observation for file mutations, and non-blocking local storage setup. Multipart cleanup is the most urgent before sustained production S3 use; this approval does not establish production readiness.
Validation: local formatting, patch/whitespace, and database/storage boundary/schema parity checks passed. Rust and API/docs CI passed. Local Rust tests could not run because the pinned object_store dependency was unavailable offline; live S3 was not exercised. The UI job still fails its dependency audit on the unchanged Next.js dependency and remains a separate CI concern.
felixgateru
left a comment
There was a problem hiding this comment.
All review comments addressed in follow up issue:
#130
Closes #127.
What
Files (profile photos, product images, documents) stored beside Atom's identities and policies: metadata and access control are Atom's, bytes live in a pluggable object store.
file: tenant, owner (uploader by default), object groups, attributes, soft delete/restore, audit andresource.*events (withkind: "file", size and type in the details).file_objects(migration 005, PostgreSQL + SQLite, native repository adapters). The storage key is never an attribute, so no GraphQL or REST input can point a file at another object.src/storage/): aBlobStoretrait with Atom-owned types (BlobKey,BlobMeta,BlobError, byte stream),BlobError → AppErrorin one place, and aStorageResolver::for_tenantseam for per-tenant buckets later.memoryadapter (tests; always compiled).object_storeadapter behind Cargo features:storage-s3(default, includeslocal; MinIO/R2/SeaweedFS/B2/Wasabi via endpoint),storage-gcs,storage-azure. Multipart for large bodies, aborted on failure.storage::conformance::run) run against memory, local,object_store's InMemory, and S3 whenATOM_TEST_S3_BUCKETis set.check-db-boundary.shnow also fails if any code outside the adapter namesobject_store::.ATOM_STORAGE_BACKENDis set; GraphQL carries no bytes):POST /files— streamed upload,ATOM_FILE_MAX_BYTESenforced fromContent-Lengthand while streaming, SHA-256 on the way through, tenant quota checked under the tenant lock. Needsmanage/writein the tenant.GET /files/{id}— public,readon the file/tenant, or a signed URL. ETag = SHA-256,If-None-Match, single byteRange.PUT /files/{id}(write) replaces bytes, same id/URL, new ETag.DELETE /files/{id}(delete) soft-deletes.POST /files/{id}/signed-url— HMAC-SHA256 under a key derived from the KEK, bounded byATOM_FILE_SIGNED_URL_MAX_TTL_SECS; verification touches no database.custom_endpointsrate-limit bucket (application traffic).ATOM_FILE_ALLOWED_TYPES. Every download sendsnosniff; only raster images and PDF are inline, everything else (SVG, HTML) is an attachment withContent-Security-Policy: sandbox.blob_deletionsqueue. Uploads queue their own key before writing and unqueue it in the committing transaction; triggers onfile_objectsqueue old keys on replacement and on every physical delete (resource purge, retention purge, tenant purge cascades). A worker claims keys older than the grace period, deletes them, and requeues failures. An upload whose key was already claimed fails with 503 and requeues it, so bytes are either referenced or collected, never both.Resource.file { sizeBytes contentType sha256 public url updatedAt }.deployment-config.json(count test 168 → 190), re-pinnedcontracts-v1.0.0.sha384, newoperations/file-storagedocs page (config, Supabase comparison, authorization, key layout, export, deletion, adding a provider), README and AGENTS.md.atom-owned/app/datato mount a volume on (e.g.ATOM_STORAGE_LOCAL_PATH=/app/data/files).Departures from the issue
pendingrows or separate sweeper. The deletion queue covers unfinished uploads: each key is queued before it is written and unqueued on commit, so one worker handles abandoned uploads, replacements and purges.public, max-age=300, notimmutable: replacement keeps the URL, so an immutable response would pin stale bytes for a year. The ETag makes revalidation cheap.manage, consistent with the GraphQL resource mutations.action_applicabilitychange: resources already allow read/write/delete/manage.ATOM_TEST_S3_BUCKETis set and skips otherwise.Testing
tests/m53_file_storage.rs(11 tests): round trip, sniffing, 304/206, public vs private, signed URLs (other file, tampered, expired, TTL bounds), tenant permissions, SVG as sandboxed attachment, oversized with and without length, quota under 6 concurrent uploads, replacement + worker, attributes cannot redirect a file, soft delete/restore keeps bytes, resource and tenant purge delete bytes, the upload/grace-period race, routes absent without storage.m41_pki_est, which needs the externalATOM_EST_CLIENTlocally.--no-default-features), boundary/parity check, v1 contract gate and regressions, GraphQL SDL diff.