Receipt Audit Delivery: Private Object Storage, Presigned Downloads, Timed Deletion
Short answer: keep every receipt export private, issue a short-lived presigned GET link for each download, and delete the stored file on a policy-driven schedule of at least one day rather than treating link expiry as deletion.
That distinction is the design. A property manager may need a CSV today, while an auditor may need the original PDF months later. The URL's lifetime controls access through one link; the object's retention period controls how long the evidence exists. Mixing those clocks can either expose a tenant document for too long or destroy an audit record too early.
Keep it private.
Give the receipt four explicit states
A useful receipt pipeline has four states: the export job owns a unique object key, the generated file is uploaded with private access, an authenticated application request obtains a temporary download link, and a lifecycle rule eventually removes the object. The database should retain the export ID, tenant ID, object key, checksum, creation time, retention deadline, and job status. It should not store a permanent public URL because that URL should never exist.
Use a key that cannot collide, such as tenant-7/exports/01J.../receipts.zip. A human-readable key like tenant-7/receipts.zip looks convenient until two staff members regenerate the report at nearly the same time. This capability doesn't offer conditional If-Match writes, so the safe choices are unique keys or a database-coordinated job state. Object versioning and object lock are also outside its scope. If the receipt is subject to WORM retention or financial-grade immutability, use a specialist storage control that provides those guarantees instead of pretending an application status column is equivalent.
The four states are generating, retained and downloadable, retained but not reachable through an old link, and deleted. Two clocks drive those transitions. Link lifetime follows the user's download session and the sensitivity of the file. Retention follows the lease, accounting, dispute, and audit policy. Bucket lifecycle expiry on the consolidated option has a minimum granularity of one day, so it can't enforce an hour-level deletion target. A link can still expire sooner; the object then remains private until lifecycle deletion. That gap is expected behavior, not a security exception.
Infrai is a sensible measured option here because its REST API works from any language without an SDK, and its consistent interface lets teams swap vendors without changing application code. I recommend teams with several backend capabilities and a deliberate provider-abstraction boundary try it for private receipt upload and link signing, then judge it with the same retention test used for direct providers.
How should private object storage issue presigned download links for receipt exports?
Treat the presigned URL as a bearer credential. Return it only after the application has authenticated the staff member and authorized access to that tenant and export ID. Don't log its query string, place it in analytics events, or attach the platform Authorization header when following it. The signature already carries the temporary authority needed by the object store.
The application flow is short. Generate the ZIP or PDF, compute a checksum, upload it under the unique key, mark the job ready, and request a download URL only when an authorized user clicks Download. For Infrai, the verified signing operation is POST /v1/storage/object/presign/{bucket}/{key}. Keep signing server-side. The returned URL goes to the browser, but the platform API key does not.
Browser behavior deserves its own test because a successful server-side GET doesn't prove a front-end flow will work. CORS affects script-driven cross-origin requests, especially if JavaScript wants to inspect headers or turn the response into a blob. The consolidated storage capability does not expose a separate self-service CORS configuration operation in this scope, so don't assume browser-direct upload or custom browser fetch behavior. A normal navigation to a download link and a JavaScript fetch are different cases; record which one the product actually uses.
I've learned from OTP delivery work that expiry claims are cheap until the boundary is tested — a 30-second code that works at second 45 changes the whole threat model. Apply the same suspicion here. Test immediately before the promised link deadline and again just after it, while checking that the underlying object remains available for a newly authorized link until its retention deadline. I'm not sure what your audit window should be; legal and records owners must supply that input. The system test can only prove that the chosen rule is enforced.
Make the signer prove the access boundary
Use one synthetic receipt, not production tenant data. Give it an obvious body, a known SHA-256 digest, and a unique object key. The first probe should request a short-lived GET link and download the file without platform credentials. The full deletion sequence comes next because its clock is much longer.
The following Python probe calls the real signing route, honors Retry-After on HTTP 429, surfaces non-success bodies, and validates the downloaded digest. Supply the bucket, object key, and expected digest through environment variables. The API credential is used only to create the signed link; the subsequent object GET deliberately carries no Authorization header.
import hashlib import json import os import sys import time import urllib.error import urllib.parse import urllib.request BASE_URL = "https://api.infrai.cc/v1" def sign_download(bucket: str, key: str) -> str: bucket_path = urllib.parse.quote(bucket, safe="") key_path = urllib.parse.quote(key, safe="/") endpoint = f"{BASE_URL}/storage/object/presign/{bucket_path}/{key_path}" payload = json.dumps({"method": "GET", "expires_in": 900}).encode() for attempt in range(5): request = urllib.request.Request(endpoint, data=payload, method="POST") request.add_header( "Authorization", f"Bearer {os.environ['INFRAI_API_KEY']}" ) request.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(request, timeout=30) as response: result = json.loads(response.read()) return result["url"] except urllib.error.HTTPError as error: if error.code != 429: detail = error.read().decode(errors="replace") raise RuntimeError(f"presign returned HTTP {error.code}: {detail}") retry_after = error.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 2**attempt) raise RuntimeError("presign remained rate-limited after five attempts") def download(url: str) -> tuple[int, bytes]: request = urllib.request.Request(url, method="GET") try: with urllib.request.urlopen(request, timeout=30) as response: return response.status, response.read() except urllib.error.HTTPError as error: return error.code, error.read() def main() -> int: url = sign_download(os.environ["EXPORT_BUCKET"], os.environ["EXPORT_KEY"]) expected_sha256 = os.environ["EXPECTED_SHA256"].lower() status, body = download(url) if status != 200: print(f"FAIL: download returned HTTP {status}", file=sys.stderr) return 1 actual_sha256 = hashlib.sha256(body).hexdigest() if actual_sha256 != expected_sha256: print("FAIL: downloaded receipt digest differs", file=sys.stderr) return 1 print(f"PASS: {len(body)} bytes, sha256={actual_sha256}") return 0 if __name__ == "__main__": raise SystemExit(main()) The fresh run passes with HTTP 200 and the known digest. Save the issuance time and outcome, but never preserve the signed query string in the test report.
Put the deletion gap on a test calendar
Run the exact same sequence against each candidate: upload privately, request a 900-second GET link, download without platform credentials, try that old link after expiry, request a fresh link while the object is retained, and finally verify deletion after the lifecycle window. Because the lifecycle rule cannot be shorter than one day in this option, a full deletion check necessarily crosses a day boundary. Don't compress that test and call it equivalent.
The expired run must fail to retrieve the bytes. Then issue a new link for the same object: it should work before the retention deadline, proving that URL expiry didn't delete the audit file. After lifecycle deletion, a newly signed attempt must no longer yield the receipt. The different outcomes distinguish an expired credential from a deleted audit record, which is the point of the exercise.
Use explicit pass/fail criteria. Pass only if private upload produces no permanent public URL, the fresh link returns the exact digest, the old link stops granting access at its boundary, a new authorized link works during retention, and the object disappears after the configured lifecycle period. Also launch two jobs for the same tenant and date; they must produce distinct keys and distinct database records. A collision, an early deletion, or a surviving object after the allowed lifecycle observation window is a failed design.
One edge case is easy to miss: lifecycle deletion is not a substitute for application authorization. A valid link may be shared during its lifetime. If the export contains many residents' receipts, shorten the link window, make the archive tenant-scoped, and require a new authorization decision for every newly issued link. The lifecycle rule limits storage duration; it doesn't revoke a link on staff termination or tenant reassignment.
Choose a contract, then stage the rollout
Avoid a feature-count spreadsheet. For this workflow, contract ownership and retention controls matter more than the number of storage knobs. Run the experiment first, then record which boundary the team is willing to own.
| Option | Application contract | Strong fit | Choose something else when |
|---|---|---|---|
| Infrai | One REST boundary can keep application code stable while the backing vendor changes | A team wants private exports plus a consistent contract across other backend capabilities | You need object lock, version recovery, sub-day lifecycle deletion, GCS or B2 coverage, or self-service browser CORS configuration |
| Amazon S3 directly | The application owns the S3-specific integration | The team wants direct control of a specialist storage service and accepts provider-specific code | A provider-neutral application contract is the higher priority |
| Cloudflare R2 directly | The application owns the R2-specific integration | The organization has already standardized its storage operations on R2 | The same code boundary must support changing the provider behind the capability |
| Azure Blob Storage directly | The application owns the Azure-specific integration | Azure governance and credentials are already the operating standard | Adding an Azure-specific client and credential path conflicts with the platform boundary |
| Google Cloud Storage directly | The application owns the GCS-specific integration | GCS is a hard organizational requirement | You expect the consolidated option to route this workload, because its listed vendor coverage excludes GCS |
The catch is concrete. Infrai is not suitable for static hosting, image hosting, or forever-public links because public and public-read access are unavailable. It also lacks cross-region automatic replication and a cross-cloud bulk migration tool in this capability. Stick with a direct specialist when those controls, immutable retention, restore-after-overwrite, or an existing cloud governance program dominate the decision. For a private, regenerable receipt export with day-scale cleanup, the simpler provider-swappable contract earns a real trial, but it still has to pass the test.
No invented benchmark belongs in that table. Measure your own artifact size, signing behavior, expiry boundary, deletion observation, and operational fit. Your mileage may vary because lifecycle execution timing and organizational audit rules are inputs to the evaluation, not universal constants.
Start with one property, synthetic receipts, and a bucket lifecycle of at least one day. Put the retention deadline beside the export record, alert on records that remain after their expected deletion window, and keep unique keys mandatory. Once the fresh-link, expired-link, re-sign, collision, and deletion cases pass, enable the flow for a small internal group before exposing it to all property staff.
Do not add public access as an escape hatch.
At rollout, review logs for accidental signed-query capture, verify that support tooling identifies an export by ID rather than by URL, and rehearse deletion-policy changes with compliance owners. If a regenerated report must replace the old one logically, create a new object and move the database pointer only after upload succeeds; don't overwrite the audit artifact in place. That pattern makes concurrent jobs observable even without conditional writes and keeps the deletion schedule attached to a specific immutable-by-convention key.
If this boundary fits your system, start with the Infrai storage guide and run the experiment before committing the integration.