Skip to content

Implementation Plan: Documint CDN Image Signing

Author: Principal Engineer Date: 2026-04-14 Status: Draft Related Issues: Analysis Issue 4, Decision Log FD-23 through FD-26 Task Directory: 3-frontend-implementation/36c-discovery-remediation/

When items with images are printed, Documint’s render servers fetch the image at its CDN URL. Because CloudFront requires signed cookies (FD-10), and Documint has no mechanism to attach cookies or auth headers, image fetches return HTTP 403 and printed labels render without images.

This plan implements Option A from the analysis: at print time, the operations backend generates a CloudFront signed URL (query-string authentication) with a 15-minute TTL for each item’s product_image. The signed URL replaces the bare CDN URL in the Documint template payload, allowing Documint to fetch the image without any authentication mechanism on its side.

  • Phase 3.6c Analysis — Issue 4 — problem statement and options explored
  • Decision Log FD-22 — dual-mode CDN auth (signed URLs already implemented in the Next.js BFF)
  • The BFF’s signUrl() in /arda-frontend-app/src/server/lib/cloudfront-signer.ts is the reference implementation for the CloudFront signing algorithm
  • Infrastructure stack (ImageStorageStack in CDK) already exports the signing key secret ARN and key pair ID as CloudFormation outputs — no infrastructure changes needed
  • CloudFront signed URL utility in common-module (Kotlin port of the BFF’s signUrl())
  • Helm/config wiring to deliver the signing key and key pair ID to the operations pod
  • Integration into ItemPrinter.render() and KanbanCardPrinter.render() to sign product_image URLs at print time (labels, breadcrumbs, and kanban cards)
  • Unit tests for the signer, integration tests for all three print paths
  • Changes to arda-frontend-app — the BFF’s signed URL/cookie flow is unrelated
  • Changes to Documint templates — product_image already accepts a URL; signed vs bare is transparent
  • Changes to PdfRenderService, DocumintProxy, ItemPrintingService, or PrintLifecycleImpl — signing is encapsulated in the printer classes
  • S3 or CloudFront distribution configuration — CloudFront natively accepts both signed cookies and signed URLs

The signing algorithm follows the AWS CloudFront signed URL specification using a custom policy:

  1. Build a JSON custom policy:
    {
    "Statement": [{
    "Resource": "<image-cdn-url>",
    "Condition": {
    "DateLessThan": { "AWS:EpochTime": <expiry-epoch-seconds> }
    }
    }]
    }
  2. Sign the policy with RSA-SHA1 using the CloudFront private key (PEM)
  3. Encode both policy and signature with CloudFront-safe Base64 (+ to -, / to ~, = to _)
  4. Append query parameters: ?Policy=<encoded>&Signature=<encoded>&Key-Pair-Id=<id>

New Utility: CloudFrontUrlSigner (common-module)

Section titled “New Utility: CloudFrontUrlSigner (common-module)”

Location: common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/cdn/CloudFrontUrlSigner.kt

class CloudFrontUrlSigner(
privateKeyPem: String,
private val keyPairId: String,
private val defaultTtl: Duration = 15.minutes
) {
private val privateKey: PrivateKey = parsePem(privateKeyPem) // fails at construction if PEM is invalid
fun sign(resourceUrl: String, ttl: Duration = defaultTtl): Result<String>
}

Construction:

  • Parses PEM private key into java.security.PrivateKey at construction time (PKCS#8 via KeyFactory.getInstance("RSA"))
  • The CDK lambda (generate-signing-key.ts:56) generates keys with privateKeyEncoding: { type: "pkcs8", format: "pem" } — only PKCS#8 format needs to be supported
  • Throws immediately on malformed PEM — this is a boot-time failure, not a runtime one

sign() returns Result<String>:

  • Builds the custom policy JSON
  • Signs with Signature.getInstance("SHA1withRSA")
  • Encodes with CloudFront-safe Base64
  • Returns Result.success with the URL + query parameters, or Result.failure(AppError.Infrastructure(...)) on crypto errors
  • Implementation uses runCatching { ... }.mapError { it.normalizeToAppError() } or equivalent to wrap JCA exceptions

The class is stateless after construction (private key is parsed once). Thread-safe — Signature instances are created per call.

The signed URL TTL defaults to 15 minutes and is configurable through the standard extras config chain:

Default in reference/item/application.conf:

extras {
printing: {
maxItemsPerRequest: 200
signedUrlTtlMinutes: 15
}
}

Override via Helm/configmap at deploy time: the configmap surfaces system.reference.item.extras.printing.signedUrlTtlMinutes which overrides the resource default through the standard HOCON merge.

In Module.kt, the value is read alongside maxItemsPerRequest:

val signedUrlTtlMinutes = if (cfg.extras.hasPath("printing.signedUrlTtlMinutes"))
cfg.extras.getInt("printing.signedUrlTtlMinutes") else 15

The Duration is constructed at module wiring time and passed to the CloudFrontUrlSigner constructor as defaultTtl.

Two new values need to flow from infrastructure to the operations pod. The read-cloudFormation-values.cmd file uses a DSL consumed by the Gradle helm plugin with three commands: readExport (reads a CloudFormation export value into a Helm value path), readSecretName (reads a CloudFormation export that contains a Secrets Manager ARN and extracts the secret name), and copyValue (copies a literal value).

1. CloudFront Key Pair ID (non-sensitive — configmap):

LayerFileChange
Helm valuesread-cloudFormation-values.cmdAdd: readExport .system.reference.item.extras.imageCdnSigningKeyPairId ${PURPOSE}-API-ImageCdnSigningKeyId
ConfigMaptemplates/configmap.yamlAdd: system.reference.item.extras.imageCdnSigningKeyPairId={{ .Values.system.reference.item.extras.imageCdnSigningKeyPairId | default "" }}

The CFN export ${PURPOSE}-API-ImageCdnSigningKeyId is published by ImageStorageStack (line 72 of image-storage.ts) and contains the CloudFront public key ID string (not an ARN).

2. CloudFront Signing Key PEM (sensitive — ExternalSecret):

LayerFileChange
Helm valuesread-cloudFormation-values.cmdAdd: readSecretName .system.reference.item.extras.imageCdnSigningKeySecretName ${PURPOSE}-API-ImageCdnSigningKeySecretArn
ExternalSecrettemplates/secrets.yamlAdd ImageCdnSigningKey data entry in the main ExternalSecret, referencing .Values.system.reference.item.extras.imageCdnSigningKeySecretName; surface as system.reference.item.extras.imageCdnSigningKeyPem={{ .ImageCdnSigningKey }} in secrets.properties

The CFN export ${PURPOSE}-API-ImageCdnSigningKeySecretArn (line 76 of image-storage.ts) is a full Secrets Manager ARN. The readSecretName command extracts the secret name portion (e.g., Alpha002-dev-ImageCdnSigningKey) for use as the ExternalSecret remoteRef.key. This follows the same pattern as pg_admin_secret_name (line 12 of read-cloudFormation-values.cmd, consumed at line 121 of secrets.yaml).

The secrets reader IAM role (defined in eks-cluster.ts:396-410) grants secretsmanager:GetSecretValue on the resource pattern:

arn:aws:secretsmanager:${region}:${account}:secret:${infraId}-*

The signing key secret is named ${fqn}-ImageCdnSigningKey where fqn = ${infraId}-${purpose} (e.g., Alpha002-dev-ImageCdnSigningKey). This matches the Alpha002-* wildcard pattern. No IAM changes are needed.

Note: The operations component’s own secrets use the ${Infrastructure}-${Namespace}-I-* naming convention (e.g., Alpha002-dev-operations-I-DocumintApiKey), but the ExternalSecret remoteRef.key accepts any secret name — the convention is not a constraint. The IAM policy is the binding authority, and it permits access to all ${infraId}-* secrets.

All failures follow the Result return pattern and the single-return-point convention. Failures surface as early as possible:

Boot-time failures (fail the component startup with AppError.Infrastructure):

  • Missing or empty imageCdnSigningKeyPairId config value → AppError.Infrastructure("imageCdnSigningKeyPairId is required")
  • Missing or empty imageCdnSigningKeyPem secret value → AppError.Infrastructure("imageCdnSigningKeyPem is required")
  • PEM parsing failure (malformed key) → AppError.Infrastructure("Failed to parse CloudFront signing key: ...")

These are configuration errors that cannot self-heal at runtime. The component must not start if the signing infrastructure is misconfigured.

Print-time failures (propagated via Result.failure to the calling route):

  • CloudFrontUrlSigner.sign() returns Result<String> — crypto failures wrap as AppError.Infrastructure("CloudFront URL signing failed: ...")
  • ItemPrinter.render() and KanbanCardPrinter.render() return Result — signing failures compose via flatMap (no getOrThrow)
  • The calling endpoint maps the failure to an appropriate HTTP error response

No silent degradation: a signing failure means the image will 403 at Documint, so the print request must fail explicitly rather than produce a broken PDF.

There are three print types across two modules, served by two printer classes:

Print TypeEndpointPrinter Classproduct_image LineModule
LabelsPOST /item/print-labelItemPrinterItemPrinter.kt:95reference/item
BreadcrumbsPOST /item/print-breadcrumbItemPrinterItemPrinter.kt:95reference/item
Kanban CardsPOST /kanban-card/print-cardKanbanCardPrinterKanbanCardPrinter.kt:62resources/kanban

Execution traces:

Labels and Breadcrumbs share the same path:

ItemEndpoint.printingRoutes → service.printLabels / service.printBreadcrumbs
→ ItemPrintingService.Impl.print()
→ itemPrinter.render(EntityRecord) → ItemPrintInfo.product_image ← SIGN HERE
→ renderService.renderGroups(groups, ...)
→ DocumintProxy.render(templateId, grid, live)

Kanban Cards:

KanbanCardEndpoint.print-card → service.printCards
→ PrintLifecycleImpl.printCards()
→ cardPrinter.render(KanbanCardDetails) → KanbanCardPrintInfo.product_image ← SIGN HERE
→ printService.renderGroups(groups, ...)
→ DocumintProxy.render(templateId, grid, live)

Both printers produce a product_image field from item.imageUrl?.toString() ?: "". Both need the signer injected.

The CloudFrontUrlSigner is built once in the item module (where the config values are scoped) and injected into both modules. This enables unit testing with a mock signer in all consumers.

Item module (reference/item/Module.kt):

In itemModule():

  1. Read imageCdnSigningKeyPairId from config extras (required — fail startup if missing/blank)
  2. Read imageCdnSigningKeyPem from secrets properties (required — fail startup if missing/blank)
  3. Read printing.signedUrlTtlMinutes from config extras (default: 15)
  4. Instantiate CloudFrontUrlSigner(pem, keyPairId, ttl) — constructor parses the PEM key; throws on invalid key format
  5. Pass the signer to ItemPrinter
  6. Return the signer alongside ItemService so callers can pass it to other modules

The item() function return type changes from ItemService to a structure that includes both:

data class ItemModuleResult(val service: ItemService, val urlSigner: CloudFrontUrlSigner)

Main.kt wiring:

val itemResult = module { item(cfgProvider, ...) } // returns ItemModuleResult
val itemService = itemResult.service
val urlSigner = itemResult.urlSigner
val kanbanService = module { kanban(cfgProvider, ..., itemService, urlSigner) }

Kanban module (resources/kanban/Module.kt):

  1. kanban() and kanbanCardModule() gain a urlSigner: CloudFrontUrlSigner parameter
  2. KanbanCardPrinter is constructed with the signer (line 77)

The signer is a required dependency in both modules, not optional. If the signing key infrastructure is not deployed, the component fails to start.

Signature changes across the codebase:

Adding CloudFrontUrlSigner as a required constructor parameter to ItemPrinter and KanbanCardPrinter will break all existing call sites and tests that construct these classes. Similarly, the item() return type change and kanban() signature change propagate through Main.kt and any test that wires these modules. The implementation agent must update:

  • All existing ItemPrinter(...) constructor calls in tests
  • All existing KanbanCardPrinter(...) constructor calls in tests
  • Main.kt module wiring
  • ComponentStartupTest and any other integration test that bootstraps these modules
  • ItemService.Impl constructor if it stores/forwards the signer

In tests, the signer can be constructed with a test RSA key or mocked.

Both printers receive a CloudFrontUrlSigner (non-nullable) as a constructor parameter.

ItemPrinter (reference/item/service/ItemPrinter.kt):

class ItemPrinter(
val qrLookupUrlBase: String,
private val urlSigner: CloudFrontUrlSigner,
private val colorResolver: (ItemColor) -> Result<ColorLookup>
)

The render() method changes from color(...).map { ... } to color(...).flatMap { ... } so that the signing Result composes within the Result chain without using getOrThrow() (prohibited by kotlin-coding rule 5). The signing call is extracted into a helper that returns Result<String>:

private fun signImageUrl(imageUrl: java.net.URL?): Result<String> =
when (imageUrl) {
null -> Result.success("")
else -> urlSigner.sign(imageUrl.toString())
}

In render(), the body changes from:

fun render(itemRd: EntityRecord<Item, ItemMetadata>): Result<ItemPrintInfo> {
// ...
return color(item, supply).map { colorLookup ->
ItemPrintInfo(
// ...
product_image = item.imageUrl?.toString() ?: "",
// ...
)
}
}

to:

fun render(itemRd: EntityRecord<Item, ItemMetadata>): Result<ItemPrintInfo> {
// ...
return color(item, supply).flatMap { colorLookup ->
signImageUrl(item.imageUrl).map { signedUrl ->
ItemPrintInfo(
// ...
product_image = signedUrl,
// ...
)
}
}
}

KanbanCardPrinter (resources/kanban/business/KanbanCardPrinter.kt) — identical pattern:

class KanbanCardPrinter(
val qrLookupUrlBase: String,
private val urlSigner: CloudFrontUrlSigner,
private val colorResolver: (ItemColor) -> Result<ColorLookup>
)

Same signImageUrl helper; render() changes color(...).map to color(...).flatMap with nested signImageUrl(...).map.

Error types: CloudFrontUrlSigner.sign() wraps crypto failures in AppError.Infrastructure("CloudFront URL signing failed: ${cause.message}"). Boot-time failures (missing config, invalid PEM) also use AppError.Infrastructure since they are configuration/infrastructure errors, not attributable to caller input.

#TaskRepositoryDepends OnAcceptance Criteria
0aBaseline: run all checks in common-modulecommon-modulemake build passes; record test count and coverage percentage
0bBaseline: run all checks in operationsoperationsmake build passes (uses --include-build ../common-module); record test count and coverage percentage
1Implement CloudFrontUrlSignercommon-module0aClass signs URLs correctly; constructor fails on invalid PEM; sign() returns Result; thread-safe
2Unit tests for CloudFrontUrlSignercommon-module1Known-answer test (fixed PKCS#8 key + timestamp = expected signature); edge cases (URL with existing query params, empty URL); TTL override; invalid PEM throws at construction
3Add Helm config wiring for key pair IDoperations0breadExport for ImageCdnSigningKeyId; configmap surfaces imageCdnSigningKeyPairId
4Add Helm secret wiring for signing key PEMoperations0breadSecretName for ImageCdnSigningKeySecretArn; ExternalSecret fetches PEM; secrets.properties surfaces imageCdnSigningKeyPem
5Wire CloudFrontUrlSigner into item Module.kt; update Main.ktoperations1, 3, 4item() returns ItemModuleResult(service, urlSigner); signer instantiated from config at boot; missing config fails startup; Main.kt updated to destructure result and pass signer to kanban; all existing ItemPrinter test call sites updated with test signer
6Wire CloudFrontUrlSigner into kanban Module.ktoperations5kanban() and kanbanCardModule() accept urlSigner param; KanbanCardPrinter receives signer; all existing KanbanCardPrinter test call sites updated with test signer
7Update ItemPrinter to sign product_imageoperations5render() signs non-null image URLs; signing failure propagates as Result.failure; null imageUrl produces empty string
8Update KanbanCardPrinter to sign product_imageoperations6render() signs non-null image URLs; signing failure propagates as Result.failure; null imageUrl produces empty string
9Unit tests for ItemPrinter signingoperations7Tests verify: signed URL format, signing failure propagation, null imageUrl
10Unit tests for KanbanCardPrinter signingoperations8Tests verify: signed URL format, signing failure propagation, null imageUrl
11Integration test: label/breadcrumb print with signed imagesoperations7End-to-end print-label call produces ItemPrintInfo with valid signed product_image URLs
12Integration test: kanban card print with signed imagesoperations8End-to-end print-card call produces KanbanCardPrintInfo with valid signed product_image URLs
13Update ComponentStartupTestoperations5, 6Startup test config includes signing key entries; startup succeeds; test with missing config verifies startup failure
14Pre-push: common-module finalizationcommon-module2CHANGELOG updated with version [X.Y.Z-jmpicnic-DINTG] under Added category; make build passes; coverage >= baseline from 0a
15Push and publish common-modulecommon-module14Feature branch pushed; CI publishes library artifact
16Pre-push: operations finalizationoperations9-13, 15CHANGELOG updated with version [X.Y.Z-jmpicnic-DINTG] under Added category; arda-common-version in gradle/libs.versions.toml updated to published feature-branch version; settings.gradle.kts includeBuild reverted; all checks pass; coverage >= baseline from 0b
17Push operations and create PRoperations16Feature branch pushed; CI green; PR description includes cross-repo dependency callout: > [!IMPORTANT] This PR depends on common-module #<N>. Please review and merge it first. and dependency on PR #163
18Byproduct generation (Phase 3)documentation17byproducts/ created with: changelog.md, learnings.md, suggestions.md, alternatives.md, skipped.md, specification-post.md
19Session logging (Phase 4)documentation18session/ created with plans, walkthroughs, and intermediate artifacts
20Knowledge publication (Phase 5)repos19Key learnings from learnings.md incorporated into relevant knowledge-base/ directories

Single agent — no parallel worktrees needed within each repository. Two repositories are modified:

WorktreeBranchBaseRepository
projects/image-upload-frontend-worktrees/common-modulejmpicnic/image-upload-printingorigin/maincommon-module
projects/image-upload-frontend-worktrees/operationsjmpicnic/image-upload-printingorigin/jmpicnic/multi-pdf-print-and-bugsoperations

Dependency: The operations PR depends on PR #163 (jmpicnic/multi-pdf-print-and-bugs) merging first. The common-module PR targets main directly.

Build verification: Use composite build for local validation:

Terminal window
cd projects/image-upload-frontend-worktrees/operations
./gradlew build --include-build ../common-module

Merge order: common-module PR merges first (publishes new version), then operations PR (after PR #163 merges and the new common-module version is available).

Before any code changes, run all checks in both repositories and record the baseline:

  • common-module: make build — record test count and coverage percentage
  • operations: make build — record test count and coverage percentage

All new code must have unit test coverage. Final coverage in both repositories must be equal to or higher than the baseline.

During development, operations depends on the local common-module via composite build. Use the worktree-aware existence check pattern from the release-lifecycle conventions:

// settings.gradle.kts — worktree-aware override (development only)
val commonModulePath = file("../common-module")
if (commonModulePath.exists()) {
includeBuild(commonModulePath)
}

Or pass via command line without modifying settings.gradle.kts:

Terminal window
./gradlew build --include-build ../common-module

Either way, this is a development-only convenience and must not be pushed. The if (exists()) guard is safer — it no-ops in CI where the sibling path doesn’t exist — but must still be reverted before push per release-lifecycle push-guard rules.

Pre-Push Checklist — common-module (Task 14)

Section titled “Pre-Push Checklist — common-module (Task 14)”
  1. Update CHANGELOG.md with a version entry using the pattern [X.Y.Z-jmpicnic-DINTG] under the Added category (new capability = minor version bump)
  2. Run make build — all checks must pass
  3. Verify coverage >= baseline from Task 0a
  1. Push the jmpicnic/image-upload-printing branch
  2. CI publishes the library artifact with the feature-branch version
  3. Verify the artifact is available in the registry before proceeding

Pre-Push Checklist — operations (Task 16)

Section titled “Pre-Push Checklist — operations (Task 16)”
  1. Update CHANGELOG.md with a version entry using the pattern [X.Y.Z-jmpicnic-DINTG] under the Added category
  2. Update arda-common-version in gradle/libs.versions.toml to the published feature-branch version from Task 15 (current: 8.1.0; update to the X.Y.Z-jmpicnic-DINTG version published by common-module CI)
  3. Revert any includeBuild("../common-module") additions in settings.gradle.kts — this file must NOT be pushed with the local override
  4. Run make build (without --include-build) — all checks must pass against the published artifact
  5. Verify coverage >= baseline from Task 0b
  1. Push the jmpicnic/image-upload-printing branch
  2. CI runs against the published common-module artifact

The implementation must follow the kotlin-coding and unit-tests-backend skills. Key rules for this task:

  • No getOrThrow() or getOrNull() — use map / flatMap to compose Result chains
  • Single exit point — no multiple return statements; use when expressions and Result chains
  • AppError subclasses for all errors — AppError.Infrastructure for config/crypto failures
  • Named arguments at call sites when constructor has multiple String parameters (e.g., CloudFrontUrlSigner(privateKeyPem = pem, keyPairId = id))
  • No !! — use requireNotNull or when/?: with AppError
  • runCatching + normalizeToAppError() for wrapping JCA crypto exceptions
  • Composite build safety — never push includeBuild overrides in settings.gradle.kts
  • Test harness pattern — use a Harness object when test setup is shared across multiple tests; inline setup is acceptable for simple cases
  • Named mock variables — never pass mockk() inline in constructors
RiskLikelihoodImpactMitigation
Signed URL TTL too short for Documint batch rendersLowMedium — images 403 on slow renders15-minute TTL is generous; Documint typically fetches within seconds. TTL configurable via Helm value for operational tuning
common-module version not available when operations PR is readyLowLow — blocks merge but not developmentUse --include-build ../common-module for local dev; coordinate merge timing
Signature change blast radius in existing testsCertainLow — test compilation failures, not logic errorsAll existing tests that construct ItemPrinter or KanbanCardPrinter need a signer parameter. Use a shared test helper that builds a signer with a fixed test RSA key
#QuestionResolutionDecision Log
1IAM permissions for signing key secretVerified. Secrets reader policy in eks-cluster.ts:409 grants access to arn:...:secret:${infraId}-*. Signing key secret ${infraId}-${purpose}-ImageCdnSigningKey matches. No IAM changes needed.
2readSecretName helper availabilityVerified. readSecretName is a DSL command in the Gradle helm plugin, used at line 12 of read-cloudFormation-values.cmd for pg_admin_secret_name. Same pattern applies for the signing key secret ARN.
3Failure mode for signing errorsDecided: fail fast. Missing config or secret values fail component startup. Runtime signing errors propagate as Result.failure through the print call chain. No silent degradation.FD-24
4Signing approach for Documint imagesDecided: Option A — CloudFront signed URLs at print time. Options B-E rejected (see decision log for rationale).FD-23
5Signer placementDecided: common-module. Shared utility, available to any backend component.FD-25
6TTL configurationDecided: configurable. Default 15 min in application.conf extras.printing.signedUrlTtlMinutes, overridable via Helm configmap.FD-26
7PEM key formatVerified: PKCS#8 only. The CDK lambda (generate-signing-key.ts:56) uses privateKeyEncoding: { type: "pkcs8", format: "pem" }. No need to support PKCS#1.
8Signer construction locationDecided: item module. Built once in itemModule() (where config is scoped), returned alongside ItemService via ItemModuleResult, injected into kanban module by Main.kt. Enables mock injection in tests.