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
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.
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.ymlSeverity: 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 signsGetObjectagainst 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):
getPresignedUrlchecks onlyreq.userauthentication before signingGetObjectresource_type, carrying no PII, wallet address, tenant slug, or counterGetObjectsigned against the bucketResponseContentDisposition/ResponseContentTypepinned, noListBucketobject_access_auditrow per signing decisionThe 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, andagreement-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
StorageObjectTypeORM entity andstorage_objectstable:id (uuid),object_key,bucket,owner_user_id,tenant_id,resource_typeenum (kyc_document | property_media | agreement),content_type,size_bytes,created_at. Object keys are generated as random ULIDs namespaced byresource_type(for examplekyc_document/<ulid>), carrying no email, wallet address, tenant slug, or sequential counter. AddObjectOwnershipGuardandStorageAuthorizationService.canAccess(principal, objectId, action)invoked byStorageService.getPresignedUrlbefore anyS3Clientcall; deny returns403mapped to aStorageAccessDeniedError. Presign scope is constrained to a singleGetObject/PutObjecton one key, withS3_PRESIGN_TTL_SECONDSbounded (read <= 300s, write <= 900s),ResponseContentDispositionandResponseContentTypepinned, and noListBucket. Record every decision through theAuditModule(AuditService.record) into anobject_access_audittable (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 underbackend/src/migrationscreate both tables and backfillstorage_objectsfrom 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/apiproxy only at the moment of view/download, via auseSignedUrl(objectId, action)TanStack Query hook withstaleTimebelow 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.tsmust 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_SECONDSand bucket/region variables to.env.exampleanddocker-compose.yml. Extendbackend-ci-cd.ymlwith a migration dry-run and the new storage-authorization test suite; extendfrontend-ci-cd.ymlwith a typecheck gate covering theuseSignedUrlhook 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, andcargo testpass; adapter assertion test proves no S3 key/URL can be written on-chain.backend/:pnpm buildandpnpm lintpass; migrations apply and revert.StorageAuthorizationService.canAccessdenies cross-owner and cross-tenant requests for eachresource_type; key generator emits non-sequential, PII-free ULIDs.objectIdreturn403; issued URLs carry TTL within configured bounds and single-key scope; every request writes oneobject_access_auditrow with correctgrantedvalue.frontendreveal-on-authorization fetches a fresh URL per view; expired URL triggers re-request; no raw key or bucket appears in the client bundle.object_access_auditstate dump for allow and deny cases, migration up/down output, and a table mapping eachresource_typeto its ownership predicate.Suggested Execution Path
Phase 1 - Backend authorization and key model (14-20h). Deliverables:
StorageObjectentity,storage_objectsandobject_access_auditmigrations, 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.getPresignedUrlgated by the guard, TTL and single-key scope enforcement,AuditServiceintegration over the Bull queue, blockchain-adapter key/URL assertion. Exit check: integration suite confirms403on non-owned objects and one audit row per decision.Phase 3 - Frontend reveal-on-authorization (8-16h). Deliverables:
useSignedUrlhook,app/apiproxy 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.ymlmigration dry-run plus storage test job,frontend-ci-cd.ymltypecheck gate, PR state dumps. Exit check: both pipelines green with new gates enforced.