Skip to content

[Backend] S3 Storage Access Control: Per-Object Ownership Authorization, Scoped Presigned URLs, and Access Audit Trail #244

Description

@david87131

Task Classification: Security hardening (access control on the object-signing path)
Affected Layers: backend, frontend (contract and root receive minimal type/CI touches)
Affected Paths: backend/src/modules/storage/, backend/src/modules/audit/, backend/src/modules/kyc/, backend/src/modules/properties/, backend/src/modules/agreements/, backend/src/migrations/, frontend/app/api/, frontend/lib/, .github/workflows/backend-ci-cd.yml, .github/workflows/frontend-ci-cd.yml
Severity: High
Estimated Window: 40-64 hours

Technical Context & Monorepo Integration Failure

The StorageModule (backend/src/modules/storage) issues S3 presigned URLs for three object classes: KYC documents (kyc), property media (properties), and rental agreements (agreements). The signing path (StorageService.getPresignedUrl) presently derives object keys from caller-supplied identifiers and signs GetObject against the configured bucket without resolving object ownership first. Authorization is enforced at the route/JWT layer (is the caller authenticated), not at the object layer (does this caller own this object). That gap spans layers: the frontend (frontend/app/api, frontend/lib) requests a URL, the backend signs it, and S3 honors the signature for any holder of the resulting link, regardless of tenant. A single-layer fix is insufficient because tightening the frontend proxy does not stop direct backend calls, bounding backend TTL alone does not stop a same-tenant-scoped principal from fetching another principal's key, and neither addresses key naming that embeds enumerable or PII-bearing identifiers.

Missing controls (control-drift, not exploitation steps):

Missing control Current behavior Invariant to enforce
Per-object ownership predicate at signing time getPresignedUrl checks only req.user authentication before signing GetObject Signature emitted only when the principal is the resolved owner or holds an explicit role grant
Non-enumerable, PII-free key naming Keys derived from sequential or email-derived identifiers Keys are random ULIDs namespaced by resource_type, carrying no PII, wallet address, tenant slug, or counter
Bounded, least-privilege presign scope Long-TTL GetObject signed against the bucket TTL bounded, single-key scope, ResponseContentDisposition/ResponseContentType pinned, no ListBucket
Access audit record No row records who requested which object One object_access_audit row per signing decision

The invariant to restore: an object is signable only when the requesting principal is its resolved owner (or holds an explicit role grant), the signature is least-privilege and short-lived, keys are non-enumerable and PII-free, and every signing decision is recorded.

Core Component Invariants & Code Paths

Smart Contract Infrastructure. No Soroban change is required. Enforce a boundary invariant instead: property_registry, user_profile, and agreement-related crates must store only content hashes (BytesN<32>), never S3 keys, bucket names, or presigned URLs, so on-chain state cannot leak object locations. Add a serialization-layer assertion in the backend blockchain adapter (backend/src/blockchain) rejecting any on-chain write whose payload matches a bucket/key pattern. Post-change invariant: on-chain document references are opaque hashes decoupled from storage keys.

Backend/API Layer. Introduce a StorageObject TypeORM entity and storage_objects table: id (uuid), object_key, bucket, owner_user_id, tenant_id, resource_type enum (kyc_document | property_media | agreement), content_type, size_bytes, created_at. Object keys are generated as random ULIDs namespaced by resource_type (for example kyc_document/<ulid>), carrying no email, wallet address, tenant slug, or sequential counter. Add ObjectOwnershipGuard and StorageAuthorizationService.canAccess(principal, objectId, action) invoked by StorageService.getPresignedUrl before any S3Client call; deny returns 403 mapped to a StorageAccessDeniedError. Presign scope is constrained to a single GetObject/PutObject on one key, with S3_PRESIGN_TTL_SECONDS bounded (read <= 300s, write <= 900s), ResponseContentDisposition and ResponseContentType pinned, and no ListBucket. Record every decision through the AuditModule (AuditService.record) into an object_access_audit table (object_id, actor_user_id, action, granted, ip, user_agent, request_id, created_at), enqueued on the existing Bull audit queue for durability. New migration files under backend/src/migrations create both tables and backfill storage_objects from existing keys. Env vars: AWS_S3_BUCKET, AWS_REGION, S3_PRESIGN_TTL_SECONDS, IAM credentials sourced via AWS Secrets Manager. Post-change invariant: no signature is emitted without a passing ownership predicate and a persisted audit row.

Frontend Client. Replace any client-held long-lived URL with reveal-on-authorization: components request a fresh short-TTL URL through the frontend/app/api proxy only at the moment of view/download, via a useSignedUrl(objectId, action) TanStack Query hook with staleTime below the presign TTL and no cache persistence to storage. The proxy forwards the JWT and never exposes the bucket, region, or raw key to the client bundle. frontend/lib/format.ts must not construct object keys; keys are opaque to the client. Post-change invariant: the browser never holds a reusable long-lived URL and cannot enumerate or synthesize keys.

Root Configuration & Orchestration. Add S3_PRESIGN_TTL_SECONDS and bucket/region variables to .env.example and docker-compose.yml. Extend backend-ci-cd.yml with a migration dry-run and the new storage-authorization test suite; extend frontend-ci-cd.yml with a typecheck gate covering the useSignedUrl hook contract. Post-change invariant: CI fails if presign TTL bounds, ownership guard tests, or key-generation tests regress.

Verification & Acceptance Criteria

  • contract/: cargo build, cargo clippy, and cargo test pass; adapter assertion test proves no S3 key/URL can be written on-chain.
  • backend/: pnpm build and pnpm lint pass; migrations apply and revert.
  • Unit: StorageAuthorizationService.canAccess denies cross-owner and cross-tenant requests for each resource_type; key generator emits non-sequential, PII-free ULIDs.
  • Integration: presign requests for a non-owned objectId return 403; issued URLs carry TTL within configured bounds and single-key scope; every request writes one object_access_audit row with correct granted value.
  • End-to-end: frontend reveal-on-authorization fetches a fresh URL per view; expired URL triggers re-request; no raw key or bucket appears in the client bundle.
  • PR attachments: sample signed-URL policy JSON (redacted), object_access_audit state dump for allow and deny cases, migration up/down output, and a table mapping each resource_type to its ownership predicate.

Suggested Execution Path

Phase 1 - Backend authorization and key model (14-20h). Deliverables: StorageObject entity, storage_objects and object_access_audit migrations, ULID key generator, StorageAuthorizationService, ObjectOwnershipGuard. Exit check: unit tests prove deny-by-default across owner/tenant/role matrices and migrations revert without residue.

Phase 2 - Signing path hardening and audit wiring (12-18h). Deliverables: StorageService.getPresignedUrl gated by the guard, TTL and single-key scope enforcement, AuditService integration over the Bull queue, blockchain-adapter key/URL assertion. Exit check: integration suite confirms 403 on non-owned objects and one audit row per decision.

Phase 3 - Frontend reveal-on-authorization (8-16h). Deliverables: useSignedUrl hook, app/api proxy that hides bucket/key, removal of persisted URLs. Exit check: end-to-end test shows per-view fetch, expiry re-request, and no key leakage in the bundle.

Phase 4 - Root, CI, and verification (6-10h). Deliverables: env and compose updates, backend-ci-cd.yml migration dry-run plus storage test job, frontend-ci-cd.yml typecheck gate, PR state dumps. Exit check: both pipelines green with new gates enforced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignbugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions