AWS Specification
Sub-system specification for Storage (AWS Resources) — the cloud infrastructure supporting image storage and delivery. This document identifies specific AWS services, configurations, and IAM policies. In scenario diagrams these resources appear as “Storage” (a single black-box participant per TD-07); the details below break that abstraction. For the structural overview of all sub-systems and their dependencies, see design.md.
Actor Requirements
Section titled “Actor Requirements”Functional
Section titled “Functional”| ID | Requirement | Scenarios | Source |
|---|---|---|---|
| AWS-FR-001 | Provide a persistent S3 bucket for image assets (no TTL expiration). Separate from the existing ephemeral upload bucket (1-day TTL for CSV processing). | S1, S2, S5, S6, S7 | DQ-2, prior research |
| AWS-FR-002 | S3 object keys shall follow the format <tenantId>/images/<uuid>.<ext> for tenant isolation and CloudFront path routing. | All upload | NFR-007, BE-FR-002, DQ-3 |
| AWS-FR-003 | Support presigned POST uploads with server-side policy enforcement: Content-Type starts-with image/, Content-Length between 1 byte and max file size, key matches tenant prefix. | S1, S2 | FR-024, TD-08 |
| AWS-FR-004 | Provide a CloudFront distribution with OAC (Origin Access Control) for serving images from the S3 bucket. The bucket shall not allow direct public access. CloudFront shall require valid signed cookies for all requests; unauthenticated requests return 403. Signed cookie policy shall scope access to the requesting tenant’s key prefix. | S4 (read), all display | NFR-014, NFR-017, NFR-018, DQ-6 |
| AWS-FR-005 | Apply a lifecycle rule on the staging/ prefix (if used) to expire orphaned uploads after 7 days. Apply a lifecycle rule to abort incomplete multipart uploads after 1 day. | — (operational) | NFR-011, NFR-012 |
| AWS-FR-006 | The S3 bucket shall be encrypted with SSE-S3 (Amazon managed keys) and have versioning enabled. | — (operational) | Prior design: Static Asset Repository |
| AWS-FR-007 | Block all public access to the S3 bucket. Only CloudFront (via OAC) and the Backend presigning role shall have access. | — (security) | DQ-1, NFR-005 |
Non-Functional
Section titled “Non-Functional”| ID | Requirement | Source |
|---|---|---|
| AWS-NFR-001 | CDN-served images shall achieve P95 latency under 500 ms for cached content in US/EU regions. | NFR-002 |
| AWS-NFR-002 | The infrastructure shall support at least 50 GB of image storage without configuration changes. | NFR-013 |
| AWS-NFR-003 | S3 bucket removal policy shall be RETAIN — the bucket survives CDK stack deletion. | Prior design: Static Asset Repository |
| AWS-NFR-004 | The CloudFront signing key pair shall be rotatable without downtime. The trusted key group supports multiple active keys for zero-downtime rotation. | NFR-020 |
Object Key Structure
Section titled “Object Key Structure”The S3 object key is the primary interface contract between the Backend (which constructs keys and generates presigned credentials), the SPA (which uploads to a specific key), and CloudFront (which routes and authorizes based on key prefix). All actors must agree on the key format.
Key Format
Section titled “Key Format”<tenantId>/images/<uuid>.<ext>| Segment | Source | Example | Purpose |
|---|---|---|---|
<tenantId> | Extracted from authenticated session context (Backend) | 1fa48bf2-3ef9-4d08-8858-29e71504a1ed | Tenant isolation. Partitions storage by tenant. CloudFront signed cookie policy is scoped to /<tenantId>/*. IAM policies can be scoped to this prefix. S3 prefix-based lifecycle rules can target specific tenants if needed. |
images | Fixed literal | images | Feature namespace. Distinguishes image assets from other asset types that may share the bucket in the future (e.g., documents, exports). Enables prefix-based lifecycle rules and CloudFront cache behaviors per feature. |
<uuid> | Generated server-side by AssetKeyGenerator (Backend) | a7c3e2f1-9b4d-4e8a-b6c5-d0f1e2a3b4c5 | Uniqueness and immutability. UUID v4 provides 122 bits of entropy — no collisions, no guessability. A new UUID is generated for every upload, including replacements. The old key’s object remains in S3 (retained for version history); the entity’s imageUrl field is updated to point to the new key. |
<ext> | Derived from the requested contentType by AssetKeyGenerator | jpg, png, webp, heic | Content-type hint. Allows S3 and CloudFront to set correct Content-Type response headers without content inspection. Mapping: image/jpeg → jpg, image/png → png, image/webp → webp, image/heic → heic, image/heif → heif. |
Full Example
Section titled “Full Example”1fa48bf2-3ef9-4d08-8858-29e71504a1ed/images/a7c3e2f1-9b4d-4e8a-b6c5-d0f1e2a3b4c5.jpgCDN URL
Section titled “CDN URL”The CDN URL is constructed by prepending the CloudFront distribution domain:
https://demo.alpha001.assets.arda.cards/1fa48bf2-.../images/a7c3e2f1-....jpgThe CdnUrlResolver class in common-module constructs this URL from the object
key and the CDN domain (injected via environment variable). It also validates
that a given URL matches this pattern — used by the Backend to reject any
imageUrl that does not originate from managed storage (TD-05, NFR-005).
Design Rationale
Section titled “Design Rationale”| Decision | Rationale |
|---|---|
| Tenant-first (not feature-first) | Tenant ID as the top-level prefix enables IAM policy scoping per tenant, CloudFront signed cookie scoping per tenant (TD-11), and future per-tenant lifecycle rules or quotas. Feature-first (images/<tenantId>/...) would require more complex IAM conditions. |
| No entity ID in key | The key does not include the entity ID (e.g., item ID). This decouples the storage key from the entity lifecycle — an image can be uploaded before the entity is created (S6: item creation flow). The link between image and entity is the imageUrl field on the entity, not the S3 key. |
| UUID per upload, not per entity | Each upload generates a new UUID, even for replacements. This makes objects effectively immutable — CloudFront can cache aggressively with long TTLs (no invalidation needed on replacement). The old object remains for version history (FR-027). |
| Extension in key | Including the file extension enables correct Content-Type headers without post-upload metadata inspection. S3 can infer content type from extension when the presigned POST does not set it explicitly. |
Constraints
Section titled “Constraints”- The Backend must generate the key server-side. The SPA never constructs keys — it receives the key (and the presigned form) from the Backend.
- The tenant ID segment must match the authenticated tenant from the request context. The Backend must not accept a client-supplied tenant ID for key construction.
- The UUID must be generated using a cryptographically secure random source
(e.g.,
java.util.UUID.randomUUID()).
Cross-References
Section titled “Cross-References”| Consumer | Usage |
|---|---|
AssetKeyGenerator (common-module) | Constructs keys in this format |
CdnUrlResolver (common-module) | Constructs CDN URLs from keys; validates URLs match this pattern |
ImageUploadEndpoint (operations) | Returns the key to the BFF/SPA alongside presigned form fields |
| Presigned POST form | Key and metadata are embedded as form fields; POST policy conditions enforce constraints |
| CloudFront signed cookie | Resource field scoped to /<tenantId>/* |
| S3 lifecycle rules | Can target staging/ prefix or specific tenant prefixes |
Interfaces
Section titled “Interfaces”S3 Bucket
Section titled “S3 Bucket”Bucket name convention: <partition>-<purpose>-image-assets
(e.g., alpha001-prod-partition-image-assets)
Inbound: SPA → S3 (Presigned POST)
Section titled “Inbound: SPA → S3 (Presigned POST)”| Operation | Path | Auth | Purpose |
|---|---|---|---|
| POST | https://<bucket>.s3.<region>.amazonaws.com | Presigned POST form with policy document | Upload image bytes (supports multipart) |
The Backend generates a presigned POST form using the AWS SDK’s
CreatePresignedPost operation. The form includes a policy document with
conditions that S3 validates server-side. This approach is chosen over
presigned PUT because POST enables content-length-range enforcement and
multipart upload support (TD-08).
Policy conditions (S3 validates server-side — upload rejected if any condition fails):
| Condition | Type | Value | Purpose |
|---|---|---|---|
key | eq | <tenantId>/images/<uuid>.<ext> | Exact key match — upload cannot target a different key |
Content-Type | starts-with | image/ | Only image content types accepted |
content-length-range | range | [1, <maxFileSizeBytes>] | Server-side file size enforcement (e.g., 1 byte to 10 MB) |
x-amz-meta-tenant-id | eq | <tenantId> | Tenant ID baked into the policy — client cannot alter |
x-amz-meta-author | eq | <authorId> | Author baked into the policy — client cannot alter |
x-amz-meta-arda-key | eq | operations/item/imageUrl/<uuid>.<ext> | Arda-Key convention baked into the policy |
x-amz-server-side-encryption | eq | AES256 | Enforce SSE-S3 encryption on upload |
| Expiry | timestamp | 15 minutes from generation | Aligned with existing CSV upload uploadSignatureDuration |
Metadata elements (adapted from the existing CSV upload pattern in
CsvS3BucketDirectAccess): The existing CSV upload uses x-amz-meta-tenant-id
and x-amz-meta-author as SigV4 signed headers on its presigned PUT-based
workflow. This design carries the same metadata elements forward, adapted to
presigned POST policy conditions (TD-08). The enforcement is equivalent: the
client must include these exact form fields or S3 rejects the upload.
Additionally, x-amz-meta-arda-key follows the
Static Asset Repository
Arda-Key convention.
Form fields returned to SPA (the SPA submits these as multipart form fields alongside the file):
key = <tenantId>/images/<uuid>.jpgContent-Type = image/jpegx-amz-meta-tenant-id = <tenantId>x-amz-meta-author = <authorId>x-amz-meta-arda-key = operations/item/imageUrl/<uuid>.jpgx-amz-server-side-encryption = AES256X-Amz-Credential = ...X-Amz-Algorithm = AWS4-HMAC-SHA256X-Amz-Date = ...Policy = <base64-encoded policy document>X-Amz-Signature = ...file = <image bytes> ← must be the last fieldSignature duration: 15 minutes (configurable via uploadSignatureDuration,
aligned with the existing CSV upload pattern).
Inbound: Backend → S3 (AWS SDK)
Section titled “Inbound: Backend → S3 (AWS SDK)”| Operation | Purpose | IAM Permission |
|---|---|---|
s3:PutObject (presigned POST generation) | Generate presigned POST forms | Via presigning role |
s3:GetObject (HEAD) | Verify uploaded object exists; validate metadata post-upload | Via pod service account role |
s3:ListBucket | List objects for future admin/cleanup operations | Via pod service account role |
Post-upload metadata validation: After the SPA reports a successful upload,
the Backend performs a HeadObject on the key and validates that
x-amz-meta-tenant-id matches the requesting tenant. This follows the same
pattern as CsvUploadService.processCsv which validates matchingAttributes
against the expected tenant-id and author after CSV upload. Defense-in-depth
even though the POST policy conditions already prevent tampering.
Inbound: CloudFront → S3 (OAC)
Section titled “Inbound: CloudFront → S3 (OAC)”| Operation | Purpose | Auth |
|---|---|---|
s3:GetObject | Serve images to CDN | Origin Access Control (SigV4) |
CloudFront Distribution
Section titled “CloudFront Distribution”Distribution purpose: Serve image assets from S3 with caching and edge delivery.
Configuration
Section titled “Configuration”| Setting | Value | Rationale |
|---|---|---|
| Origin | S3 bucket (OAC) | Secure origin access without public bucket |
| Origin Access Control | SigV4 signing | Modern replacement for OAI |
| Viewer access restriction | Trusted key groups (signed cookies) | Product images are sensitive (TD-11). All viewer requests must carry valid CloudFront signed cookies. Unauthenticated requests receive 403. |
| Trusted key group | RSA key pair managed via CDK / Secrets Manager | BFF holds the private key to sign cookies. CloudFront holds the public key to verify. Key group supports multiple active keys for zero-downtime rotation. |
| Cache behavior path | /* (all objects in bucket) | Single-purpose bucket; all objects are images |
| Cache policy | CachingOptimized (or custom with long TTL) | Images are immutable (new UUID on replacement). Signed cookies are not part of the cache key — all users in the same tenant share cached responses. |
| Viewer protocol policy | HTTPS only | Security |
| Allowed HTTP methods | GET, HEAD | Read-only CDN |
| Price class | PriceClass_100 (US, Canada, Europe) | Cost optimization for initial deployment |
| Default TTL | 86400 (24 hours) | Immutable objects; long caching is safe |
| Max TTL | 31536000 (1 year) | With Cache-Control: immutable from S3 |
Cache Invalidation
Section titled “Cache Invalidation”Cache invalidation is not required for normal operations. Object keys
include a UUID, so image replacement creates a new key — the old key’s cache
entry naturally expires. The entity’s imageUrl field is updated to point to
the new key, so all subsequent requests go to the new object.
Invalidation may be needed for operational scenarios (e.g., removing harmful content). This is a manual or automated process outside the normal application flow.
Custom Domain
Section titled “Custom Domain”| Setting | Value |
|---|---|
| Domain | <partition>.<infrastructure>.assets.arda.cards (e.g., demo.alpha001.assets.arda.cards) |
| Certificate | ACM certificate for *.arda.cards (or specific subdomain) |
| Route 53 | CNAME/alias record pointing to CloudFront distribution |
IAM Roles
Section titled “IAM Roles”Presigning Role
Section titled “Presigning Role”Role name convention: <partition>-image-upload-presigning-role
Follows the same pattern as the existing UploadPreSigningRole for CSV uploads
in public-upload-bucket.ts.
| Permission | Resource | Condition |
|---|---|---|
s3:PutObject | arn:aws:s3:::<bucket>/* | aws:SecureTransport: true, s3:x-amz-server-side-encryption: AES256 |
s3:GetObject | arn:aws:s3:::<bucket>/* | aws:SecureTransport: true |
The Backend assumes this role via sts:AssumeRole to generate presigned POST
forms. The role is separate from the pod’s service account role to maintain
least-privilege separation. The bucket policy mirrors these conditions —
PutObject requires HTTPS and SSE-S3 (AES256).
Pod Service Account Role
Section titled “Pod Service Account Role”Existing role, extended with permissions for the new bucket:
| Permission | Resource | Purpose |
|---|---|---|
s3:GetObject | arn:aws:s3:::<bucket>/* | HEAD verification of uploaded objects |
s3:ListBucket | arn:aws:s3:::<bucket> | Admin/cleanup operations |
sts:AssumeRole | Presigning role ARN | Assume presigning role for POST form generation |
S3 Object Metadata
Section titled “S3 Object Metadata”Each uploaded image object carries S3 metadata. All metadata keys listed below
are enforced via presigned POST policy conditions — the SPA must include
them as form fields with the exact values specified in the policy, or S3
rejects the upload. The metadata elements are adapted from the existing CSV
upload pattern (CsvS3BucketDirectAccess) where x-amz-meta-tenant-id and
x-amz-meta-author are used as signed headers on the CSV presigned PUT-based
workflow. This design carries the same elements forward, adapted to presigned
POST policy conditions (TD-08).
| Metadata Key | Value | Policy Condition | Purpose |
|---|---|---|---|
x-amz-meta-tenant-id | Tenant UUID | eq (exact match) | Audit trail, tenant identification. Validated post-upload via HeadObject (same as CSV upload matchingAttributes validation). |
x-amz-meta-author | User UUID (from JWT sub) | eq (exact match) | Audit trail. Identifies who uploaded the image. |
x-amz-meta-arda-key | operations/item/imageUrl/<uuid>.<ext> | eq (exact match) | Human-readable reference following the Arda-Key convention from the Static Asset Repository design. |
Content-Type | image/jpeg, image/png, etc. | starts-with image/ | Only image content types accepted. |
x-amz-server-side-encryption | AES256 | eq (exact match) | Enforce SSE-S3 encryption (same requirement as CSV upload bucket policy). |
Post-upload validation: After upload, the Backend calls HeadObject and
validates that x-amz-meta-tenant-id matches the expected tenant (defense-in-
depth). This mirrors CsvUploadService.processCsv which validates
matchingAttributes before processing.
S3 Lifecycle Rules
Section titled “S3 Lifecycle Rules”| Rule | Prefix | Action | Days |
|---|---|---|---|
| Abort incomplete multipart uploads | (all) | AbortIncompleteMultipartUpload | 1 |
| Expire orphaned staging objects | staging/ (if staging prefix used) | Expiration | 7 |
Modules
Section titled “Modules”CDK Constructs (New)
Section titled “CDK Constructs (New)”| Construct | Location | Purpose |
|---|---|---|
ImageAssetBucket | infrastructure/src/main/cdk/constructs/storage/image-asset-bucket.ts | S3 bucket for persistent image assets. Parameterized from UploadBucket pattern but with no TTL expiration, versioning enabled, OAC integration. |
ImageAssetCdn | infrastructure/src/main/cdk/constructs/xgress/image-asset-cdn.ts | CloudFront distribution with OAC origin to image asset bucket. Custom domain, HTTPS-only, caching optimized. Configured with trusted key groups for signed cookie validation (TD-11). |
ImageUploadPresigningRole | infrastructure/src/main/cdk/constructs/iam/image-upload-presigning-role.ts | IAM role for presigned POST form generation. s3:PutObject + s3:GetObject on image asset bucket. Follows the same pattern as UploadPreSigningRole in public-upload-bucket.ts (HTTPS required, SSE-S3 required). |
CloudFrontSigningKeyGroup | infrastructure/src/main/cdk/constructs/xgress/cloudfront-signing-key-group.ts | RSA key pair and CloudFront key group for signed cookie validation. Public key uploaded to CloudFront; private key stored in Secrets Manager for BFF consumption. Supports multiple active keys for zero-downtime rotation. |
CDK Stacks (Modified)
Section titled “CDK Stacks (Modified)”| Stack | Location | Change |
|---|---|---|
PartitionBulkStoresStack | infrastructure/src/main/cdk/stacks/purpose/partition-bulk-stores.ts | Add ImageAssetBucket and ImageUploadPresigningRole. Export bucket ARN, name, and presigning role ARN via cross-stack exports. |
PartitionIngressStack (or new stack) | infrastructure/src/main/cdk/stacks/purpose/ | Add ImageAssetCdn distribution. Export CDN domain name. |
CloudFormation (Modified)
Section titled “CloudFormation (Modified)”| File | Change |
|---|---|
operations/src/main/cloudformation/pre-install.cfn.yml | Import new bucket ARN and presigning role ARN from infrastructure exports. Add IAM permissions (s3:GetObject, sts:AssumeRole) to pod service account role. |
Cross-Stack Exports
Section titled “Cross-Stack Exports”| Export Key | Value | Consumer |
|---|---|---|
${Infrastructure}-${Purpose}-API-ImageAssetBucketArn | Bucket ARN | Operations CloudFormation |
${Infrastructure}-${Purpose}-API-ImageAssetBucketName | Bucket name | Operations CloudFormation |
${Infrastructure}-${Purpose}-API-ImagePresignRoleArn | Presigning role ARN | Operations CloudFormation |
${Infrastructure}-${Purpose}-API-ImageCdnDomain | CloudFront domain | Operations (for CDN URL construction) |
${Infrastructure}-${Purpose}-API-ImageCdnSigningKeyId | CloudFront key pair ID | BFF (for cookie signing) |
${Infrastructure}-${Purpose}-API-ImageCdnSigningKeySecretArn | Secrets Manager ARN for private key | BFF (for cookie signing) |
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved