From 4f96b698009de6210fad2c0f7af0b830df15b670 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:54:09 +0900 Subject: [PATCH 01/47] fix: rebuild tenant-scoped administrator authorization on current parent --- CHANGELOG.md | 6 +- ...2026-08-05-administrative-authorization.md | 71 ++++ .../audit/AdministrativeAuditLogger.java | 167 ++++++++ .../viewer/auth/TenantAccessService.java | 32 ++ .../viewer/auth/TenantPermissions.java | 10 + .../viewer/controller/AdminController.java | 237 +++++++++-- .../viewer/security/AuditPseudonymizer.java | 24 ++ .../DefaultDocumentConversionService.java | 60 ++- .../service/DocumentConversionService.java | 30 ++ src/main/resources/application-buyer-demo.yml | 4 +- .../audit/AdministrativeAuditLoggerTest.java | 202 +++++++++ .../TenantAccessServiceStrictClaimsTest.java | 113 +++++ .../BuyerDemoSecretConfigurationTest.java | 30 ++ ...ControllerSignedClaimsRequirementTest.java | 53 +++ ...nControllerTenantMutationBoundaryTest.java | 127 ++++++ .../controller/AdminControllerTest.java | 391 +++++++++++++++--- .../security/AuditPseudonymizerTest.java | 4 + .../TenantScopedRetryContractTest.java | 197 +++++++++ 18 files changed, 1673 insertions(+), 85 deletions(-) create mode 100644 docs/security/2026-08-05-administrative-authorization.md create mode 100644 src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java create mode 100644 src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java create mode 100644 src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceStrictClaimsTest.java create mode 100644 src/test/java/com/clearfolio/viewer/config/BuyerDemoSecretConfigurationTest.java create mode 100644 src/test/java/com/clearfolio/viewer/controller/AdminControllerSignedClaimsRequirementTest.java create mode 100644 src/test/java/com/clearfolio/viewer/controller/AdminControllerTenantMutationBoundaryTest.java create mode 100644 src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5ba27b2..428a9a0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ### Security - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. +- 관리자 API에 서명된 tenant claim 검증, `admin:read`/`admin:write` 최소 권한, tenant 소유권 검사를 적용했습니다. 누락 및 cross-tenant 객체는 동일한 not-found 응답으로 은폐합니다. +- 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해, controller 우회 호출에서도 소유권 검사가 적용되고 read-then-mutate 간격이 생기지 않도록 했습니다. +- 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다. +- buyer-demo profile의 tenant-claims HMAC secret 환경변수 직접 바인딩을 제거하고 공통 Spring config-tree secret mount에서 읽도록 변경했습니다. # Changelog @@ -54,4 +58,4 @@ - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. ### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md new file mode 100644 index 000000000..57004e575 --- /dev/null +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -0,0 +1,71 @@ +# Tenant-scoped administrative authorization + +## Decision + +Clearfolio's administrative job endpoints are not global superuser APIs. They are tenant-scoped operations that evaluate signed subject claims, an explicit administrative permission, the requested operation, and the target job's tenant ownership on every request. + +The implementation follows deny-by-default and least-privilege principles. Listing requires `admin:read`; deletion and dead-letter retry require `admin:write`. Possessing an opaque UUID is never sufficient authorization. Missing and cross-tenant jobs intentionally return the same not-found response so an object identifier cannot be used to enumerate another tenant's documents or operational state. + +Administrative endpoints never fall back to unsigned demo-header mode. If the tenant-claims HMAC verifier is absent or its configured key contains fewer than 32 UTF-8 bytes, the endpoints return `503 Service Unavailable` before repository access. This makes a missing or weak trust anchor an observable deployment failure rather than an authorization bypass. + +## Trust boundary + +The `X-Clearfolio-*` claim headers are an internal adapter contract between Clearfolio and an authenticated gateway or host such as naruon. They are not public client credentials. The upstream gateway must authenticate the caller, construct canonical tenant, subject, permission, and issue-time claims, and sign them with the tenant-claims HMAC key. + +Clearfolio verifies the signature and freshness before evaluating permissions. Deployments must strip untrusted inbound copies of these headers before adding verified claims. The service remains standalone because the claim verifier is an injectable component, but production must not expose the internal header adapter directly to arbitrary clients. + +The tenant-claims HMAC secret is read from the shared Spring config-tree secret mount as `clearfolio.tenant-claims.hmac-secret`. The buyer-demo profile no longer maps a secret-bearing environment variable directly into runtime configuration. Environment variables may select non-secret operational values or bootstrap a mounted credential store, but runtime authentication reads the mounted property. The mounted tenant-claims key must contain at least 32 UTF-8 bytes for privileged administrative endpoints. + +## Authorization sequence + +Every endpoint applies the same fail-closed sequence: + +1. Confirm that a strong signed-claim verifier is configured; otherwise return `503` before service access. +2. Parse the tenant, subject, permissions, issue time, and claim signature. +3. Verify signed claims and their freshness. +4. Require the action-specific permission. +5. Pass the verified `TenantContext` into the object-specific service mutation. +6. Select the target through a tenant-scoped repository lookup before evaluating or applying the mutation. +7. Return a non-enumerating not-found response for absent or cross-tenant objects. +8. Emit privacy-safe authorization evidence for the resulting outcome. + +List responses filter the complete repository result to the verified tenant before applying the optional dead-letter status filter. Delete and retry do not perform controller-level read-then-write authorization. Their service contracts receive the verified tenant context and enforce ownership at the mutation boundary, so non-HTTP callers cannot reach an unscoped administrative mutation by bypassing the controller. + +The legacy two-argument retry method remains only as a compatibility contract for non-administrative adapters. Administrative HTTP flows call the tenant-aware three-argument method. The durable implementation performs the tenant-scoped selection before invoking the shared state transition and therefore does not rely on a controller-side ownership check. + +## Audit evidence + +Administrative evidence contains only: + +- a controlled action code; +- a controlled outcome code; +- HTTP status; +- tenant and actor HMAC fingerprints in separate domains; +- an opaque job UUID when applicable; +- a numeric result count for list operations. + +It does not contain raw tenant identifiers, raw subject identifiers, claim signatures, permission headers, filenames, job messages, document text, or artifact bytes. The retry provenance stored with a job uses the actor-domain fingerprint rather than the source subject identifier. Pseudonymized values remain personal data and inherit the retention, access, rotation, and incident-response requirements in `2026-08-04-audit-pseudonymization.md`. + +## Verification requirements + +Automated tests must exercise the real signed-claim verifier and prove: + +- absent and weak verifier keys make privileged endpoints unavailable before service access; +- missing, malformed, expired, and incorrectly signed claims fail before service access; +- missing `admin:read` or `admin:write` permissions fail before service access; +- list results contain only tenant-owned jobs for all dead-letter filter states; +- missing and cross-tenant delete/retry targets produce indistinguishable not-found responses; +- delete and retry cross a tenant-aware service boundary without a separate controller lookup or unscoped administrative mutation call; +- the durable retry service rejects null, missing, and cross-tenant contexts without state transition or worker enqueue; +- accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; +- not-found, not-eligible, repository failure, deletion failure, and retry failure paths return stable non-leaking responses; +- audit output contains no raw tenant, subject, signature, filename, or document data; +- JaCoCo reports 100% line and branch coverage for the `com.clearfolio.viewer.*` production package. + +## References + +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2014). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162 + +OWASP Foundation. (2023). *API1:2023 broken object level authorization*. OWASP API Security Top 10. https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ + +OWASP Foundation. (n.d.). *Authorization cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html diff --git a/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java b/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java new file mode 100644 index 000000000..b678279d4 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java @@ -0,0 +1,167 @@ +package com.clearfolio.viewer.audit; + +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.security.AuditPseudonymizer; + +/** + * Emits structured, privacy-safe administrative authorization evidence. + * + *

Actor and tenant identifiers are pseudonymized in separate HMAC domains. + * The logger never emits raw claim headers, subject identifiers, tenant + * identifiers, tokens, filenames, job messages, or document content.

+ */ +@Component +public final class AdministrativeAuditLogger { + + private static final Logger LOGGER = LoggerFactory.getLogger(AdministrativeAuditLogger.class); + private static final String NO_JOB_ID = "none"; + private static final int NO_RESULT_COUNT = -1; + + private final AuditPseudonymizer actorPseudonymizer; + private final AuditPseudonymizer tenantPseudonymizer; + + /** + * Administrative actions represented in authorization evidence. + */ + public enum Action { + /** Lists tenant-owned conversion jobs. */ + LIST_JOBS, + /** Deletes one tenant-owned conversion job. */ + DELETE_JOB, + /** Retries one tenant-owned dead-lettered conversion job. */ + RETRY_JOB + } + + /** + * Stable outcomes represented in authorization evidence. + */ + public enum Outcome { + /** Authorization and the requested operation succeeded. */ + ALLOWED, + /** Authentication or permission evaluation denied the request. */ + DENIED, + /** The resource was absent or intentionally concealed. */ + NOT_FOUND, + /** The resource existed but its state rejected the operation. */ + NOT_ELIGIBLE, + /** Authorization succeeded but the operation failed unexpectedly. */ + FAILED + } + + /** + * Creates the logger from the dedicated audit pseudonym configuration. + * + * @param properties conversion and audit configuration + */ + public AdministrativeAuditLogger(ConversionProperties properties) { + this.actorPseudonymizer = AuditPseudonymizer.forAdministrativeActor( + properties.getAuditPseudonymSecret(), + properties.getAuditPseudonymKeyVersion() + ); + this.tenantPseudonymizer = AuditPseudonymizer.forAdministrativeTenant( + properties.getAuditPseudonymSecret(), + properties.getAuditPseudonymKeyVersion() + ); + } + + /** + * Returns a privacy-safe actor identifier suitable for retry provenance. + * + * @param context authenticated tenant context, or null when unavailable + * @return administrative actor fingerprint + */ + public String actorFingerprint(TenantContext context) { + return actorPseudonymizer.fingerprint(context == null ? null : context.subjectId()); + } + + /** + * Records an authorization decision using authenticated context values. + * + * @param context authenticated tenant context, or null when unavailable + * @param action administrative action + * @param outcome decision outcome + * @param status response status + * @param jobId optional opaque job identifier + * @param resultCount optional list result count + */ + public void record( + TenantContext context, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId, + Integer resultCount + ) { + recordIdentifiers( + context == null ? null : context.tenantId(), + context == null ? null : context.subjectId(), + action, + outcome, + status, + jobId, + resultCount + ); + } + + /** + * Records a denied request using untrusted headers only as HMAC inputs. + * + * @param headers request headers, or null when unavailable + * @param action administrative action + * @param outcome decision outcome + * @param status response status + * @param jobId optional opaque job identifier + */ + public void recordHeaders( + HttpHeaders headers, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId + ) { + recordIdentifiers( + firstHeader(headers, TenantContext.TENANT_ID_HEADER), + firstHeader(headers, TenantContext.SUBJECT_ID_HEADER), + action, + outcome, + status, + jobId, + null + ); + } + + private void recordIdentifiers( + String tenantId, + String subjectId, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId, + Integer resultCount + ) { + LOGGER.info( + "Administrative access decision action={} outcome={} status={} " + + "tenantFingerprint={} actorFingerprint={} jobId={} resultCount={}", + action, + outcome, + status.value(), + tenantPseudonymizer.fingerprint(tenantId), + actorPseudonymizer.fingerprint(subjectId), + jobId == null ? NO_JOB_ID : jobId, + resultCount == null ? NO_RESULT_COUNT : resultCount + ); + } + + private String firstHeader(HttpHeaders headers, String name) { + return headers == null ? null : headers.getFirst(name); + } +} diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java b/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java index ab02e96a7..ef5a32d86 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java @@ -26,6 +26,7 @@ public class TenantAccessService { private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final int MIN_SIGNED_CLAIMS_SECRET_BYTES = 32; private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); private final String claimsHmacSecret; @@ -61,6 +62,10 @@ public TenantAccessService( /** * Resolves tenant claims and verifies the required permission. * + *

This method preserves the repository's explicit unsigned demo mode for + * non-privileged local flows. Privileged endpoints must use + * {@link #requireSigned(HttpHeaders, String)} instead.

+ * * @param headers request headers * @param permission required permission * @return verified tenant context @@ -81,6 +86,27 @@ public TenantContext require(HttpHeaders headers, String permission) { return context; } + /** + * Resolves claims for a privileged endpoint and requires a strong verifier. + * + *

The endpoint is unavailable rather than falling back to unsigned + * client-supplied headers when the signed-claim HMAC secret is absent or + * contains fewer than 32 UTF-8 bytes.

+ * + * @param headers request headers + * @param permission required permission + * @return verified tenant context + */ + public TenantContext requireSigned(HttpHeaders headers, String permission) { + if (!hasStrongSignedClaimsVerifier()) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, + "signed auth verifier unavailable" + ); + } + return require(headers, permission); + } + /** * Hides resources that do not belong to the request tenant. * @@ -93,6 +119,12 @@ public void requireSameTenant(TenantContext context, ConversionJob job) { } } + private boolean hasStrongSignedClaimsVerifier() { + return claimsHmacSecret != null + && claimsHmacSecret.getBytes(StandardCharsets.UTF_8).length + >= MIN_SIGNED_CLAIMS_SECRET_BYTES; + } + private void requireSignedClaimsWhenConfigured(HttpHeaders headers, TenantContext context) { if (claimsHmacSecret == null) { return; diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3d..5d537241b 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -50,6 +50,16 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to list tenant-scoped administrative job state. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to mutate tenant-scoped administrative job state. + */ + public static final String ADMIN_WRITE = "admin:write"; + private TenantPermissions() { } } diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb86..872f0a66e 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,88 +4,269 @@ import java.util.List; import java.util.UUID; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import com.clearfolio.viewer.api.AdminJobListResponse; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Action; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Outcome; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; /** - * Controller for admin-specific endpoints. + * Exposes tenant-scoped administrative conversion-job operations. + * + *

Every endpoint requires strongly configured signed gateway claims, + * evaluates a least-privilege permission, and delegates object-level tenant + * authorization to the same service boundary that performs each mutation. + * Missing and cross-tenant objects intentionally share the same not-found + * response.

*/ @RestController public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; + private final AdministrativeAuditLogger auditLogger; /** - * Creates a controller for admin operations. + * Creates a controller for authenticated tenant-administrator operations. * * @param conversionService conversion service + * @param tenantAccessService signed-claim authorization service + * @param auditLogger privacy-safe administrative evidence logger */ - public AdminController(DocumentConversionService conversionService) { + public AdminController( + DocumentConversionService conversionService, + TenantAccessService tenantAccessService, + AdministrativeAuditLogger auditLogger + ) { this.conversionService = conversionService; + this.tenantAccessService = tenantAccessService; + this.auditLogger = auditLogger; } /** - * Retrieves all conversion jobs, optionally filtered by dead-letter status. + * Retrieves conversion jobs owned by the authenticated tenant. * - * @param deadLettered optional filter for dead-lettered jobs - * @return list of conversion jobs + * @param deadLettered optional dead-letter status filter + * @param headers signed gateway claim headers + * @return tenant-scoped list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { - Iterable allJobs = conversionService.getAllJobs(); + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) Boolean deadLettered, + @RequestHeader HttpHeaders headers + ) { + TenantContext context = authorize( + headers, + TenantPermissions.ADMIN_READ, + Action.LIST_JOBS, + null + ); - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - - List filtered = new ArrayList<>(); - for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { - filtered.add(job); + try { + List filtered = new ArrayList<>(); + for (ConversionJob job : conversionService.getAllJobs()) { + boolean tenantOwned = job.belongsToTenant(context.tenantId()); + boolean deadLetterMatches = deadLettered == null + || job.isDeadLettered() == deadLettered; + if (tenantOwned && deadLetterMatches) { + filtered.add(job); + } } + auditLogger.record( + context, + Action.LIST_JOBS, + Outcome.ALLOWED, + HttpStatus.OK, + null, + filtered.size() + ); + return AdminJobListResponse.from(filtered); + } catch (RuntimeException ex) { + auditLogger.record( + context, + Action.LIST_JOBS, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + null, + null + ); + throw ex; } - return AdminJobListResponse.from(filtered); } /** - * Deletes a conversion job. + * Deletes one conversion job owned by the authenticated tenant. * * @param jobId conversion job identifier + * @param headers signed gateway claim headers * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { - conversionService.deleteJob(jobId); + public ResponseEntity deleteJob( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers + ) { + TenantContext context = authorize( + headers, + TenantPermissions.ADMIN_WRITE, + Action.DELETE_JOB, + jobId + ); + + boolean deleted; + try { + deleted = conversionService.deleteJob(jobId, context); + } catch (RuntimeException ex) { + auditLogger.record( + context, + Action.DELETE_JOB, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + jobId, + null + ); + throw ex; + } + + if (!deleted) { + auditLogger.record( + context, + Action.DELETE_JOB, + Outcome.NOT_FOUND, + HttpStatus.NOT_FOUND, + jobId, + null + ); + throw notFound(); + } + + auditLogger.record( + context, + Action.DELETE_JOB, + Outcome.ALLOWED, + HttpStatus.NO_CONTENT, + jobId, + null + ); return ResponseEntity.noContent().build(); } /** - * Retries a dead-lettered conversion job. + * Retries one dead-lettered conversion job owned by the authenticated tenant. * * @param jobId conversion job identifier + * @param headers signed gateway claim headers * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); - if (result == RetryDeadLetterResult.NOT_FOUND) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + public ResponseEntity retryDeadLettered( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers + ) { + TenantContext context = authorize( + headers, + TenantPermissions.ADMIN_WRITE, + Action.RETRY_JOB, + jobId + ); + + RetryDeadLetterResult result; + try { + result = conversionService.retryDeadLettered( + jobId, + context, + auditLogger.actorFingerprint(context) + ); + } catch (RuntimeException ex) { + auditLogger.record( + context, + Action.RETRY_JOB, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + jobId, + null + ); + throw ex; } - if (result == RetryDeadLetterResult.NOT_ELIGIBLE) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "job is not eligible for retry"); + + return switch (result) { + case ACCEPTED -> { + auditLogger.record( + context, + Action.RETRY_JOB, + Outcome.ALLOWED, + HttpStatus.ACCEPTED, + jobId, + null + ); + yield ResponseEntity.accepted().build(); + } + case NOT_FOUND -> { + auditLogger.record( + context, + Action.RETRY_JOB, + Outcome.NOT_FOUND, + HttpStatus.NOT_FOUND, + jobId, + null + ); + throw notFound(); + } + case NOT_ELIGIBLE -> { + auditLogger.record( + context, + Action.RETRY_JOB, + Outcome.NOT_ELIGIBLE, + HttpStatus.CONFLICT, + jobId, + null + ); + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "job is not eligible for retry" + ); + } + }; + } + + private TenantContext authorize( + HttpHeaders headers, + String permission, + Action action, + UUID jobId + ) { + try { + return tenantAccessService.requireSigned(headers, permission); + } catch (ResponseStatusException ex) { + auditLogger.recordHeaders( + headers, + action, + Outcome.DENIED, + ex.getStatusCode(), + jobId + ); + throw ex; } - return ResponseEntity.accepted().build(); + } + + private ResponseStatusException notFound() { + return new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); } } diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java index ed3bd31db..961ae02c5 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -21,6 +21,8 @@ public final class AuditPseudonymizer { private static final String HMAC_SHA_256 = "HmacSHA256"; private static final String DEFAULT_KEY_VERSION = "v1"; private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final String ADMIN_ACTOR_DOMAIN = "clearfolio:audit-admin-actor:v1"; + private static final String ADMIN_TENANT_DOMAIN = "clearfolio:audit-admin-tenant:v1"; private static final int FINGERPRINT_BYTES = 16; private static final int MIN_SECRET_BYTES = 32; private static final int MAX_KEY_VERSION_LENGTH = 32; @@ -41,6 +43,28 @@ public AuditPseudonymizer(String secret, String keyVersion) { this(secret, keyVersion, APPROVER_DOMAIN); } + /** + * Creates a pseudonymizer for administrative actor identifiers. + * + * @param secret dedicated audit pseudonym secret + * @param keyVersion non-sensitive key-rotation identifier + * @return actor-domain pseudonymizer + */ + public static AuditPseudonymizer forAdministrativeActor(String secret, String keyVersion) { + return new AuditPseudonymizer(secret, keyVersion, ADMIN_ACTOR_DOMAIN); + } + + /** + * Creates a pseudonymizer for administrative tenant identifiers. + * + * @param secret dedicated audit pseudonym secret + * @param keyVersion non-sensitive key-rotation identifier + * @return tenant-domain pseudonymizer + */ + public static AuditPseudonymizer forAdministrativeTenant(String secret, String keyVersion) { + return new AuditPseudonymizer(secret, keyVersion, ADMIN_TENANT_DOMAIN); + } + /** * Creates a pseudonymizer with an explicit domain for isolated internal use * and domain-separation verification. diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index ec1d22cc7..df3dd13e3 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -102,6 +102,18 @@ public DefaultDocumentConversionService( ); } + /** + * Creates the conversion service with repository-backed lifecycle state and + * an isolated in-memory artifact store. + * + *

This convenience constructor is intended for tests and legacy wiring + * that do not provide lifecycle and artifact-store collaborators directly.

+ * + * @param repository conversion job repository + * @param validationService document validation service + * @param conversionWorker conversion worker + * @param conversionProperties conversion configuration values + */ public DefaultDocumentConversionService( ConversionJobRepository repository, DocumentValidationService validationService, @@ -111,11 +123,21 @@ public DefaultDocumentConversionService( repository, validationService, conversionWorker, - new com.clearfolio.viewer.artifact.InMemoryArtifactStore(), + new InMemoryArtifactStore(), conversionProperties ); } + /** + * Creates the conversion service with repository-backed lifecycle state and + * the supplied artifact store. + * + * @param repository conversion job repository + * @param validationService document validation service + * @param conversionWorker conversion worker + * @param artifactStore generated artifact store used for PDF passthrough seeding + * @param conversionProperties conversion configuration values + */ public DefaultDocumentConversionService( ConversionJobRepository repository, DocumentValidationService validationService, @@ -225,12 +247,46 @@ public void deleteJob(UUID jobId) { repository.deleteById(jobId); } + /** + * {@inheritDoc} + */ + @Override + public RetryDeadLetterResult retryDeadLettered( + UUID jobId, + TenantContext tenantContext, + String operatorId + ) { + if (tenantContext == null) { + return RetryDeadLetterResult.NOT_FOUND; + } + + Optional existing = repository.findByTenantAndId( + tenantContext.tenantId(), + jobId + ); + return retryExistingJob(existing, operatorId); + } + /** * {@inheritDoc} */ @Override public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { - Optional existing = repository.findById(jobId); + return retryExistingJob(repository.findById(jobId), operatorId); + } + + /** + * Applies the retry transition to a job that has already been selected by + * the caller's required authorization scope. + * + * @param existing selected conversion job, or empty when no authorized job exists + * @param operatorId privacy-safe operator fingerprint recorded by the state store + * @return accepted, not-found, or not-eligible retry result + */ + private RetryDeadLetterResult retryExistingJob( + Optional existing, + String operatorId + ) { if (existing.isEmpty()) { return RetryDeadLetterResult.NOT_FOUND; } diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java index 6676f5610..6ff0aad4b 100644 --- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java @@ -60,6 +60,36 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T */ RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId); + /** + * Retries a dead-lettered conversion job owned by the supplied tenant. + * + *

The default implementation preserves compatibility for adapters that + * have not yet implemented an atomic tenant-aware transition. Durable + * implementations should override this method so ownership and transition + * are enforced within one persistence boundary.

+ * + * @param jobId conversion job identifier + * @param tenantContext tenant and subject claims for the retry request + * @param operatorId privacy-safe operator fingerprint that triggered retry + * @return accepted, not-found, or not-eligible retry outcome + */ + default RetryDeadLetterResult retryDeadLettered( + UUID jobId, + TenantContext tenantContext, + String operatorId + ) { + if (tenantContext == null) { + return RetryDeadLetterResult.NOT_FOUND; + } + + Optional job = getJob(jobId); + if (job.isEmpty() || !job.get().belongsToTenant(tenantContext.tenantId())) { + return RetryDeadLetterResult.NOT_FOUND; + } + + return retryDeadLettered(jobId, operatorId); + } + /** * Deletes a conversion job owned by the supplied tenant context. * diff --git a/src/main/resources/application-buyer-demo.yml b/src/main/resources/application-buyer-demo.yml index 4e4df4091..bdddfe5af 100644 --- a/src/main/resources/application-buyer-demo.yml +++ b/src/main/resources/application-buyer-demo.yml @@ -25,7 +25,9 @@ clearfolio: artifact-token: secret: ${CLEARFOLIO_ARTIFACT_TOKEN_SECRET:} tenant-claims: - hmac-secret: ${CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET:} + # `hmac-secret` is loaded from the config-tree secret mount configured in + # application.yml. Runtime authentication must not read secret material + # directly from an environment placeholder. max-skew-seconds: ${CLEARFOLIO_TENANT_CLAIMS_MAX_SKEW_SECONDS:300} artifact-link-ledger: path: ${CLEARFOLIO_ARTIFACT_LINK_LEDGER_PATH:} diff --git a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java new file mode 100644 index 000000000..2e1fbd1ee --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java @@ -0,0 +1,202 @@ +package com.clearfolio.viewer.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; + +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Action; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Outcome; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; + +class AdministrativeAuditLoggerTest { + + private static final String AUDIT_SECRET = "0123456789abcdef0123456789abcdef"; + + @Test + void recordsAuthenticatedContextWithoutRawIdentifiers() { + AdministrativeAuditLogger auditLogger = configuredLogger(AUDIT_SECRET, "rotation-8"); + TenantContext context = new TenantContext( + "sensitive-tenant", + "employee-007@example.com", + Set.of("admin:read") + ); + CapturingAppender appender = attachAppender(); + + try { + auditLogger.record( + context, + Action.LIST_JOBS, + Outcome.ALLOWED, + HttpStatus.OK, + null, + 2 + ); + } finally { + appender.closeAndDetach(); + } + + String message = appender.singleMessage(); + assertTrue(message.contains("action=LIST_JOBS")); + assertTrue(message.contains("outcome=ALLOWED")); + assertTrue(message.contains("status=200")); + assertTrue(message.contains("jobId=none")); + assertTrue(message.contains("resultCount=2")); + assertTrue(message.matches(".*tenantFingerprint=rotation-8:[0-9a-f]{32}.*")); + assertTrue(message.matches(".*actorFingerprint=rotation-8:[0-9a-f]{32}.*")); + assertFalse(message.contains("sensitive-tenant")); + assertFalse(message.contains("employee-007@example.com")); + assertEquals("absent:rotation-8", auditLogger.actorFingerprint(null)); + assertNotEquals( + auditLogger.actorFingerprint(context), + message.substring( + message.indexOf("tenantFingerprint=") + "tenantFingerprint=".length(), + message.indexOf(" actorFingerprint=") + ) + ); + } + + @Test + void recordsUntrustedHeadersOnlyAsPseudonyms() { + AdministrativeAuditLogger auditLogger = configuredLogger(AUDIT_SECRET, "v2"); + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, "tenant-from-untrusted-header"); + headers.set(TenantContext.SUBJECT_ID_HEADER, "subject-from-untrusted-header"); + UUID jobId = UUID.randomUUID(); + CapturingAppender appender = attachAppender(); + + try { + auditLogger.recordHeaders( + headers, + Action.DELETE_JOB, + Outcome.DENIED, + HttpStatus.FORBIDDEN, + jobId + ); + } finally { + appender.closeAndDetach(); + } + + String message = appender.singleMessage(); + assertTrue(message.contains("action=DELETE_JOB")); + assertTrue(message.contains("outcome=DENIED")); + assertTrue(message.contains("status=403")); + assertTrue(message.contains("jobId=" + jobId)); + assertTrue(message.contains("resultCount=-1")); + assertFalse(message.contains("tenant-from-untrusted-header")); + assertFalse(message.contains("subject-from-untrusted-header")); + } + + @Test + void usesExplicitAbsentAndUnavailableMarkers() { + AdministrativeAuditLogger unavailableLogger = configuredLogger("", "v9"); + HttpHeaders presentHeaders = new HttpHeaders(); + presentHeaders.set(TenantContext.TENANT_ID_HEADER, "tenant"); + presentHeaders.set(TenantContext.SUBJECT_ID_HEADER, "subject"); + CapturingAppender appender = attachAppender(); + + try { + unavailableLogger.recordHeaders( + presentHeaders, + Action.RETRY_JOB, + Outcome.DENIED, + HttpStatus.UNAUTHORIZED, + null + ); + unavailableLogger.recordHeaders( + null, + Action.RETRY_JOB, + Outcome.DENIED, + HttpStatus.UNAUTHORIZED, + null + ); + unavailableLogger.record( + null, + Action.RETRY_JOB, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + null, + null + ); + } finally { + appender.closeAndDetach(); + } + + List messages = appender.messages(); + assertEquals(3, messages.size()); + assertTrue(messages.get(0).contains("tenantFingerprint=unavailable:v9")); + assertTrue(messages.get(0).contains("actorFingerprint=unavailable:v9")); + assertTrue(messages.get(1).contains("tenantFingerprint=absent:v9")); + assertTrue(messages.get(1).contains("actorFingerprint=absent:v9")); + assertTrue(messages.get(2).contains("tenantFingerprint=absent:v9")); + assertTrue(messages.get(2).contains("actorFingerprint=absent:v9")); + } + + private static AdministrativeAuditLogger configuredLogger(String secret, String version) { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(secret); + properties.setAuditPseudonymKeyVersion(version); + return new AdministrativeAuditLogger(properties); + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(AdministrativeAuditLogger.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "administrative-audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private List messages() { + return List.copyOf(messages); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceStrictClaimsTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceStrictClaimsTest.java new file mode 100644 index 000000000..67ac3b0a7 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceStrictClaimsTest.java @@ -0,0 +1,113 @@ +package com.clearfolio.viewer.auth; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Defines the fail-closed signed-claim contract for privileged endpoints. + */ +class TenantAccessServiceStrictClaimsTest { + + private static final Instant NOW = Instant.parse("2026-08-05T00:00:00Z"); + private static final String STRONG_SECRET = "0123456789abcdef0123456789abcdef"; + + @Test + void requireSignedRejectsMissingVerifierSecret() { + TenantAccessService blankSecret = new TenantAccessService( + " ", + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + TenantAccessService nullSecret = new TenantAccessService( + null, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, assertThrows( + ResponseStatusException.class, + () -> blankSecret.requireSigned( + unsignedHeaders(TenantPermissions.ADMIN_READ), + TenantPermissions.ADMIN_READ + ) + ).getStatusCode()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, assertThrows( + ResponseStatusException.class, + () -> nullSecret.requireSigned( + unsignedHeaders(TenantPermissions.ADMIN_READ), + TenantPermissions.ADMIN_READ + ) + ).getStatusCode()); + } + + @Test + void requireSignedRejectsWeakConfiguredVerifierSecret() { + TenantAccessService weakSecret = new TenantAccessService( + "short-secret", + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> weakSecret.requireSigned( + unsignedHeaders(TenantPermissions.ADMIN_READ), + TenantPermissions.ADMIN_READ + ) + ); + + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, exception.getStatusCode()); + } + + @Test + void requireSignedAcceptsStrongFreshSignedClaims() { + TenantAccessService service = new TenantAccessService( + STRONG_SECRET, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + HttpHeaders headers = signedHeaders(TenantPermissions.ADMIN_READ); + + TenantContext context = assertDoesNotThrow( + () -> service.requireSigned(headers, TenantPermissions.ADMIN_READ) + ); + + assertEquals(TenantContext.DEMO_TENANT_ID, context.tenantId()); + assertEquals(TenantContext.DEMO_SUBJECT_ID, context.subjectId()); + } + + private static HttpHeaders unsignedHeaders(String permission) { + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); + headers.set(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); + headers.set(TenantContext.PERMISSIONS_HEADER, permission); + return headers; + } + + private static HttpHeaders signedHeaders(String permission) { + HttpHeaders headers = unsignedHeaders(permission); + String issuedAt = Long.toString(NOW.getEpochSecond()); + TenantContext context = new TenantContext( + TenantContext.DEMO_TENANT_ID, + TenantContext.DEMO_SUBJECT_ID, + Set.of(permission) + ); + headers.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, issuedAt); + headers.set( + TenantContext.CLAIMS_SIGNATURE_HEADER, + TenantAccessService.signClaims(context, issuedAt, STRONG_SECRET) + ); + return headers; + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerDemoSecretConfigurationTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerDemoSecretConfigurationTest.java new file mode 100644 index 000000000..9af6ec444 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/BuyerDemoSecretConfigurationTest.java @@ -0,0 +1,30 @@ +package com.clearfolio.viewer.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class BuyerDemoSecretConfigurationTest { + + @Test + void tenantClaimsSecretComesFromTheSharedConfigTree() throws IOException { + String baseConfiguration = readResource("application.yml"); + String buyerDemoConfiguration = readResource("application-buyer-demo.yml"); + + assertTrue(baseConfiguration.contains( + "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" + )); + assertFalse(buyerDemoConfiguration.contains("CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET")); + assertFalse(buyerDemoConfiguration.contains("hmac-secret: ${")); + } + + private static String readResource(String name) throws IOException { + return new ClassPathResource(name) + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerSignedClaimsRequirementTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerSignedClaimsRequirementTest.java new file mode 100644 index 000000000..618540846 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerSignedClaimsRequirementTest.java @@ -0,0 +1,53 @@ +package com.clearfolio.viewer.controller; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.reactive.server.WebTestClient; + +import com.clearfolio.viewer.audit.AdministrativeAuditLogger; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.service.DocumentConversionService; + +/** + * Proves privileged endpoints never fall back to unsigned demo-header mode. + */ +class AdminControllerSignedClaimsRequirementTest { + + private static final String AUDIT_SECRET = "0123456789abcdef0123456789abcdef"; + + @Test + void administrativeListIsUnavailableWithoutAConfiguredSignedClaimVerifier() { + DocumentConversionService conversionService = mock(DocumentConversionService.class); + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_SECRET); + properties.setAuditPseudonymKeyVersion("admin-v1"); + AdminController controller = new AdminController( + conversionService, + new TenantAccessService("", 300L), + new AdministrativeAuditLogger(properties) + ); + WebTestClient client = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + HttpHeaders unsignedHeaders = new HttpHeaders(); + unsignedHeaders.set(TenantContext.TENANT_ID_HEADER, "tenant-north"); + unsignedHeaders.set(TenantContext.SUBJECT_ID_HEADER, "administrator@example.com"); + unsignedHeaders.set(TenantContext.PERMISSIONS_HEADER, TenantPermissions.ADMIN_READ); + + client.get() + .uri("/api/v1/admin/convert/jobs") + .headers(target -> target.addAll(unsignedHeaders)) + .exchange() + .expectStatus().isEqualTo(503) + .expectBody() + .jsonPath("$.errorCode").isEqualTo("SERVICE_UNAVAILABLE"); + + verifyNoInteractions(conversionService); + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTenantMutationBoundaryTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTenantMutationBoundaryTest.java new file mode 100644 index 000000000..bbd47537d --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTenantMutationBoundaryTest.java @@ -0,0 +1,127 @@ +package com.clearfolio.viewer.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.reactive.server.WebTestClient; + +import com.clearfolio.viewer.audit.AdministrativeAuditLogger; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.security.AuditPseudonymizer; +import com.clearfolio.viewer.service.DocumentConversionService; +import com.clearfolio.viewer.service.RetryDeadLetterResult; + +/** + * Verifies that administrator mutations cross a tenant-scoped service boundary. + * + *

The controller must not authorize by reading an object and then invoke an + * unscoped mutation in a separate step. Tenant ownership is part of the service + * mutation contract so non-HTTP callers and future persistence adapters cannot + * bypass or race the controller-level check.

+ */ +class AdminControllerTenantMutationBoundaryTest { + + private static final String CLAIM_SECRET = "tenant-claims-" + "integration-secret"; + private static final String AUDIT_SECRET = "0123456789abcdef".repeat(2); + private static final String TENANT_ID = "tenant-north"; + private static final String SUBJECT_ID = "administrator@example.com"; + + @Test + void deleteUsesTheTenantScopedServiceMutationWithoutASeparateLookup() { + DocumentConversionService conversionService = mock(DocumentConversionService.class); + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(any(UUID.class), any(TenantContext.class))) + .thenReturn(true); + + WebTestClient client = client(conversionService); + + client.delete() + .uri("/api/v1/admin/convert/jobs/{jobId}", jobId) + .headers(target -> target.addAll(signedHeaders(TenantPermissions.ADMIN_WRITE))) + .exchange() + .expectStatus().isNoContent(); + + verify(conversionService).deleteJob( + eq(jobId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void retryUsesTheTenantScopedServiceMutationWithoutASeparateLookup() { + DocumentConversionService conversionService = mock(DocumentConversionService.class); + UUID jobId = UUID.randomUUID(); + String actorFingerprint = AuditPseudonymizer.forAdministrativeActor( + AUDIT_SECRET, + "admin-v1" + ).fingerprint(SUBJECT_ID); + when(conversionService.retryDeadLettered( + any(UUID.class), + any(TenantContext.class), + any(String.class) + )).thenReturn(RetryDeadLetterResult.ACCEPTED); + + WebTestClient client = client(conversionService); + + client.post() + .uri("/api/v1/admin/convert/jobs/{jobId}/retry", jobId) + .headers(target -> target.addAll(signedHeaders(TenantPermissions.ADMIN_WRITE))) + .exchange() + .expectStatus().isAccepted(); + + verify(conversionService).retryDeadLettered( + eq(jobId), + argThat(context -> TENANT_ID.equals(context.tenantId())), + eq(actorFingerprint) + ); + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).retryDeadLettered(any(UUID.class), any(String.class)); + } + + private static WebTestClient client(DocumentConversionService conversionService) { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_SECRET); + properties.setAuditPseudonymKeyVersion("admin-v1"); + AdminController controller = new AdminController( + conversionService, + new TenantAccessService(CLAIM_SECRET, 300L), + new AdministrativeAuditLogger(properties) + ); + return WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + } + + private static HttpHeaders signedHeaders(String... permissions) { + LinkedHashSet permissionSet = new LinkedHashSet<>(List.of(permissions)); + TenantContext context = new TenantContext(TENANT_ID, SUBJECT_ID, permissionSet); + String issuedAt = Long.toString(Instant.now().getEpochSecond()); + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, TENANT_ID); + headers.set(TenantContext.SUBJECT_ID_HEADER, SUBJECT_ID); + headers.set(TenantContext.PERMISSIONS_HEADER, String.join(",", permissions)); + headers.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, issuedAt); + headers.set( + TenantContext.CLAIMS_SIGNATURE_HEADER, + TenantAccessService.signClaims(context, issuedAt, CLAIM_SECRET) + ); + return headers; + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..5cd95f2bb 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,124 +1,409 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import java.util.Arrays; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.security.AuditPseudonymizer; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; class AdminControllerTest { + private static final String CLAIM_SECRET = "tenant-claims-" + "integration-secret"; + private static final String AUDIT_SECRET = "0123456789abcdef".repeat(2); + private static final String TENANT_ID = "tenant-north"; + private static final String SUBJECT_ID = "administrator@example.com"; + private DocumentConversionService conversionService; private WebTestClient webTestClient; - private AdminController controller; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_SECRET); + properties.setAuditPseudonymKeyVersion("admin-v1"); + AdminController controller = new AdminController( + conversionService, + new TenantAccessService(CLAIM_SECRET, 300L), + new AdministrativeAuditLogger(properties) + ); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } @Test - void getAllJobsReturnsAllJobsWhenNoFilterProvided() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); - + void missingClaimsAreDeniedBeforeServiceAccess() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.jobs.length()").isEqualTo(2) - .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") - .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); + .expectStatus().isUnauthorized(); + + verifyNoInteractions(conversionService); } @Test - void getAllJobsFiltersByDeadLetteredTrue() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + void malformedExpiredAndInvalidSignedClaimsAreDenied() { + HttpHeaders malformed = unsignedClaimHeaders( + TENANT_ID, + SUBJECT_ID, + TenantPermissions.ADMIN_READ + ); + malformed.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, "not-an-epoch"); + malformed.set(TenantContext.CLAIMS_SIGNATURE_HEADER, "ignored"); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + requestJobs(malformed).expectStatus().isUnauthorized(); + requestJobs(signedHeadersAt( + TENANT_ID, + SUBJECT_ID, + Instant.now().minusSeconds(1_000L).getEpochSecond(), + TenantPermissions.ADMIN_READ + )).expectStatus().isUnauthorized(); - webTestClient.get() - .uri("/api/v1/admin/convert/jobs?deadLettered=true") - .exchange() - .expectStatus().isOk() - .expectBody() - .jsonPath("$.jobs.length()").isEqualTo(1) - .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + HttpHeaders invalidSignature = signedHeaders( + TENANT_ID, + SUBJECT_ID, + TenantPermissions.ADMIN_READ + ); + invalidSignature.set(TenantContext.CLAIMS_SIGNATURE_HEADER, "invalid-signature"); + requestJobs(invalidSignature).expectStatus().isUnauthorized(); + + verifyNoInteractions(conversionService); } @Test - void getAllJobsFiltersByDeadLetteredFalse() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + void missingReadPermissionIsDeniedBeforeServiceAccess() { + requestJobs(signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.JOB_READ)) + .expectStatus().isForbidden(); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + verifyNoInteractions(conversionService); + } - webTestClient.get() - .uri("/api/v1/admin/convert/jobs?deadLettered=false") - .exchange() + @Test + void listReturnsOnlyTenantOwnedJobsAndAppliesDeadLetterFilter() { + ConversionJob deadLettered = job(TENANT_ID, "dead.pdf", true); + ConversionJob ready = job(TENANT_ID, "ready.pdf", false); + ConversionJob otherTenant = job("tenant-south", "secret.pdf", false); + when(conversionService.getAllJobs()).thenReturn(List.of(deadLettered, ready, otherTenant)); + HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_READ); + + requestJobs(headers) + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(2) + .jsonPath("$.jobs[0].fileName").isEqualTo("dead.pdf") + .jsonPath("$.jobs[1].fileName").isEqualTo("ready.pdf"); + + requestJobs(headers, "?deadLettered=true") .expectStatus().isOk() .expectBody() .jsonPath("$.jobs.length()").isEqualTo(1) - .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf"); + .jsonPath("$.jobs[0].fileName").isEqualTo("dead.pdf"); + + requestJobs(headers, "?deadLettered=false") + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("ready.pdf"); + + verify(conversionService, times(3)).getAllJobs(); + } + + @Test + void listServiceFailureReturnsGenericInternalError() { + when(conversionService.getAllJobs()).thenThrow(new IllegalStateException("repository unavailable")); + + requestJobs(signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_READ)) + .expectStatus().is5xxServerError() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("INTERNAL_ERROR") + .jsonPath("$.message").isEqualTo("Unexpected error"); } @Test - void deleteJobReturnsNoContent() { + void readOnlyAdministratorCannotDelete() { UUID jobId = UUID.randomUUID(); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(target -> target.addAll(signedHeaders( + TENANT_ID, + SUBJECT_ID, + TenantPermissions.ADMIN_READ + ))) .exchange() - .expectStatus().isNoContent(); + .expectStatus().isForbidden(); + + verifyNoInteractions(conversionService); + } + + @Test + void deleteConcealsMissingAndCrossTenantServiceOutcomes() { + UUID missingId = UUID.randomUUID(); + UUID crossTenantId = UUID.randomUUID(); + when(conversionService.deleteJob(eq(missingId), any(TenantContext.class))) + .thenReturn(false); + when(conversionService.deleteJob(eq(crossTenantId), any(TenantContext.class))) + .thenReturn(false); + HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE); + + deleteJob(missingId, headers).expectStatus().isNotFound(); + deleteJob(crossTenantId, headers).expectStatus().isNotFound(); + + verify(conversionService).deleteJob( + eq(missingId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService).deleteJob( + eq(crossTenantId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService, never()).getJob(any(UUID.class)); + verify(conversionService, never()).deleteJob(any(UUID.class)); } @Test - void retryDeadLetteredReturnsAcceptedWhenAccepted() { + void deleteUsesTenantScopedServiceAndReportsFailuresGenerically() { + UUID successId = UUID.randomUUID(); + UUID lookupFailureId = UUID.randomUUID(); + UUID deleteFailureId = UUID.randomUUID(); + when(conversionService.deleteJob(eq(successId), any(TenantContext.class))) + .thenReturn(true); + when(conversionService.deleteJob(eq(lookupFailureId), any(TenantContext.class))) + .thenThrow(new IllegalStateException("lookup failed")); + when(conversionService.deleteJob(eq(deleteFailureId), any(TenantContext.class))) + .thenThrow(new IllegalStateException("delete failed")); + HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE); + + deleteJob(successId, headers).expectStatus().isNoContent(); + deleteJob(lookupFailureId, headers).expectStatus().is5xxServerError(); + deleteJob(deleteFailureId, headers).expectStatus().is5xxServerError(); + + verify(conversionService).deleteJob( + eq(successId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService).deleteJob( + eq(lookupFailureId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService).deleteJob( + eq(deleteFailureId), + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService, never()).getJob(any(UUID.class)); + verify(conversionService, never()).deleteJob(any(UUID.class)); + } + + @Test + void retryAcceptedUsesDomainSeparatedActorFingerprint() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + String expectedActor = AuditPseudonymizer.forAdministrativeActor( + AUDIT_SECRET, + "admin-v1" + ).fingerprint(SUBJECT_ID); + when(conversionService.retryDeadLettered( + eq(jobId), + any(TenantContext.class), + eq(expectedActor) + )).thenReturn(RetryDeadLetterResult.ACCEPTED); - webTestClient.post() - .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() + retryJob(jobId, signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE)) .expectStatus().isAccepted(); + + verify(conversionService).retryDeadLettered( + eq(jobId), + argThat(context -> TENANT_ID.equals(context.tenantId())), + eq(expectedActor) + ); + verify(conversionService, never()).getJob(any(UUID.class)); + verify(conversionService, never()).retryDeadLettered(any(UUID.class), any(String.class)); + verify(conversionService, never()).retryDeadLettered(jobId, SUBJECT_ID); } @Test - void retryDeadLetteredReturnsNotFoundWhenNotFound() { - UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + void retryConcealsMissingAndCrossTenantServiceOutcomes() { + UUID missingId = UUID.randomUUID(); + UUID crossTenantId = UUID.randomUUID(); + when(conversionService.retryDeadLettered( + eq(missingId), + any(TenantContext.class), + any(String.class) + )).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.retryDeadLettered( + eq(crossTenantId), + any(TenantContext.class), + any(String.class) + )).thenReturn(RetryDeadLetterResult.NOT_FOUND); + HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE); - webTestClient.post() - .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() - .expectStatus().isNotFound(); + retryJob(missingId, headers).expectStatus().isNotFound(); + retryJob(crossTenantId, headers).expectStatus().isNotFound(); + + verify(conversionService).retryDeadLettered( + eq(missingId), + argThat(context -> TENANT_ID.equals(context.tenantId())), + any(String.class) + ); + verify(conversionService).retryDeadLettered( + eq(crossTenantId), + argThat(context -> TENANT_ID.equals(context.tenantId())), + any(String.class) + ); + verify(conversionService, never()).getJob(any(UUID.class)); + verify(conversionService, never()).retryDeadLettered(any(UUID.class), any(String.class)); } @Test - void retryDeadLetteredReturnsConflictWhenNotEligible() { + void retryMapsTenantScopedServiceOutcomesWithoutLeakingJobState() { + UUID disappearedId = UUID.randomUUID(); + UUID ineligibleId = UUID.randomUUID(); + when(conversionService.retryDeadLettered( + eq(disappearedId), + any(TenantContext.class), + any(String.class) + )).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.retryDeadLettered( + eq(ineligibleId), + any(TenantContext.class), + any(String.class) + )).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE); + + retryJob(disappearedId, headers).expectStatus().isNotFound(); + retryJob(ineligibleId, headers).expectStatus().isEqualTo(409); + } + + @Test + void retryServiceFailureReturnsGenericInternalError() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(conversionService.retryDeadLettered( + eq(jobId), + any(TenantContext.class), + any(String.class) + )).thenThrow(new IllegalStateException("queue unavailable")); + + retryJob(jobId, signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_WRITE)) + .expectStatus().is5xxServerError() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("INTERNAL_ERROR"); + } - webTestClient.post() + private WebTestClient.ResponseSpec requestJobs(HttpHeaders headers) { + return requestJobs(headers, ""); + } + + private WebTestClient.ResponseSpec requestJobs(HttpHeaders headers, String query) { + return webTestClient.get() + .uri("/api/v1/admin/convert/jobs" + query) + .headers(target -> target.addAll(headers)) + .exchange(); + } + + private WebTestClient.ResponseSpec deleteJob(UUID jobId, HttpHeaders headers) { + return webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(target -> target.addAll(headers)) + .exchange(); + } + + private WebTestClient.ResponseSpec retryJob(UUID jobId, HttpHeaders headers) { + return webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() - .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + .headers(target -> target.addAll(headers)) + .exchange(); + } + + private static HttpHeaders signedHeaders( + String tenantId, + String subjectId, + String... permissions + ) { + return signedHeadersAt( + tenantId, + subjectId, + Instant.now().getEpochSecond(), + permissions + ); + } + + private static HttpHeaders signedHeadersAt( + String tenantId, + String subjectId, + long issuedAtEpoch, + String... permissions + ) { + LinkedHashSet permissionSet = new LinkedHashSet<>(List.of(permissions)); + TenantContext context = new TenantContext(tenantId, subjectId, permissionSet); + String issuedAt = Long.toString(issuedAtEpoch); + HttpHeaders headers = unsignedClaimHeaders(tenantId, subjectId, permissions); + headers.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, issuedAt); + headers.set( + TenantContext.CLAIMS_SIGNATURE_HEADER, + TenantAccessService.signClaims(context, issuedAt, CLAIM_SECRET) + ); + return headers; + } + + private static HttpHeaders unsignedClaimHeaders( + String tenantId, + String subjectId, + String... permissions + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, tenantId); + headers.set(TenantContext.SUBJECT_ID_HEADER, subjectId); + headers.set(TenantContext.PERMISSIONS_HEADER, String.join(",", permissions)); + return headers; + } + + private static ConversionJob job(String tenantId, String fileName, boolean deadLettered) { + return job(tenantId, fileName, deadLettered, UUID.randomUUID()); + } + + private static ConversionJob job( + String tenantId, + String fileName, + boolean deadLettered, + UUID jobId + ) { + ConversionJob job = new ConversionJob( + jobId, + tenantId, + "owner", + fileName, + "application/pdf", + "hash", + 100L, + 3 + ); + if (deadLettered) { + job.markDeadLettered("failed"); + } + return job; } } diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java index ce9b01b7f..b50d05a9a 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java @@ -113,6 +113,10 @@ void defaultsOnlyMissingKeyVersionAndRejectsInvalidExplicitValues() { IllegalArgumentException.class, () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1 ") ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " v1 ") + ); assertThrows( IllegalArgumentException.class, () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "bad/version") diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java new file mode 100644 index 000000000..f6f165d46 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java @@ -0,0 +1,197 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.model.ConversionJobStatus; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Verifies the compatibility and durable-service contracts for tenant-scoped + * dead-letter retry operations. + */ +class TenantScopedRetryContractTest { + + @Test + void interfaceDefaultRejectsUnownedJobsBeforeLegacyMutation() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = job(jobId, "tenant-a", "default-contract"); + AtomicReference retriedJobId = new AtomicReference<>(); + AtomicReference retriedOperatorId = new AtomicReference<>(); + DocumentConversionService service = new DocumentConversionService() { + @Override + public UUID submit(MultipartFile file) { + return UUID.randomUUID(); + } + + @Override + public Optional getJob(UUID requestedJobId) { + return jobId.equals(requestedJobId) ? Optional.of(job) : Optional.empty(); + } + + @Override + public RetryDeadLetterResult retryDeadLettered(UUID requestedJobId, String operatorId) { + retriedJobId.set(requestedJobId); + retriedOperatorId.set(operatorId); + return RetryDeadLetterResult.ACCEPTED; + } + + @Override + public void deleteJob(UUID requestedJobId) { + } + + @Override + public Iterable getAllJobs() { + return java.util.List.of(job); + } + }; + TenantContext tenantA = new TenantContext("tenant-a", "subject-a", Set.of()); + TenantContext tenantB = new TenantContext("tenant-b", "subject-b", Set.of()); + + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(jobId, null, "operator-null") + ); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(UUID.randomUUID(), tenantA, "operator-missing") + ); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(jobId, tenantB, "operator-cross-tenant") + ); + assertEquals( + RetryDeadLetterResult.ACCEPTED, + service.retryDeadLettered(jobId, tenantA, "operator-owned") + ); + assertEquals(jobId, retriedJobId.get()); + assertEquals("operator-owned", retriedOperatorId.get()); + } + + @Test + void durableServiceRejectsNullMissingAndCrossTenantJobsWithoutMutation() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + RecordingConversionWorker worker = new RecordingConversionWorker(); + DocumentConversionService service = service(repository, worker); + ConversionJob job = deadLetteredJob(UUID.randomUUID(), "tenant-a", "durable-reject"); + repository.save(job); + + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(job.getJobId(), null, "operator-null") + ); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered( + UUID.randomUUID(), + new TenantContext("tenant-a", "subject-a", Set.of()), + "operator-missing" + ) + ); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered( + job.getJobId(), + new TenantContext("tenant-b", "subject-b", Set.of()), + "operator-cross-tenant" + ) + ); + assertEquals(ConversionJobStatus.FAILED, job.getStatus()); + assertTrue(job.isDeadLettered()); + assertEquals(0, worker.enqueuedCount()); + } + + @Test + void durableServiceMapsOwnedEligibilityAndAcceptsOwnedDeadLetteredJob() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + RecordingConversionWorker worker = new RecordingConversionWorker(); + DocumentConversionService service = service(repository, worker); + TenantContext tenant = new TenantContext("tenant-a", "subject-a", Set.of()); + ConversionJob active = job(UUID.randomUUID(), "tenant-a", "durable-active"); + ConversionJob deadLettered = deadLetteredJob( + UUID.randomUUID(), + "tenant-a", + "durable-accepted" + ); + repository.save(active); + repository.save(deadLettered); + + assertEquals( + RetryDeadLetterResult.NOT_ELIGIBLE, + service.retryDeadLettered(active.getJobId(), tenant, "operator-active") + ); + assertEquals( + RetryDeadLetterResult.ACCEPTED, + service.retryDeadLettered(deadLettered.getJobId(), tenant, "operator-owned") + ); + assertEquals(ConversionJobStatus.SUBMITTED, deadLettered.getStatus()); + assertTrue(deadLettered.getStatusMessage().contains("operator-owned")); + assertEquals(1, worker.enqueuedCount()); + assertEquals(deadLettered.getJobId(), worker.lastEnqueuedJobId()); + } + + private static DocumentConversionService service( + InMemoryConversionJobRepository repository, + RecordingConversionWorker worker + ) { + return new DefaultDocumentConversionService( + repository, + new DefaultDocumentValidationService(new ConversionProperties()), + worker, + new InMemoryArtifactStore(), + new ConversionProperties() + ); + } + + private static ConversionJob deadLetteredJob(UUID jobId, String tenantId, String hash) { + ConversionJob job = job(jobId, tenantId, hash); + assertTrue(job.markProcessing("first attempt")); + job.markDeadLettered("retries exhausted"); + return job; + } + + private static ConversionJob job(UUID jobId, String tenantId, String hash) { + return new ConversionJob( + jobId, + tenantId, + "subject-a", + "contract.docx", + "application/octet-stream", + hash, + 1L, + 3 + ); + } + + private static final class RecordingConversionWorker implements ConversionWorker { + private final AtomicInteger count = new AtomicInteger(); + private final AtomicReference lastJobId = new AtomicReference<>(); + + @Override + public void enqueue(UUID jobId) { + lastJobId.set(jobId); + count.incrementAndGet(); + } + + int enqueuedCount() { + return count.get(); + } + + UUID lastEnqueuedJobId() { + return lastJobId.get(); + } + } +} From 9c66efc3c68825558db47e31fff8f30a0a34237d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 14:10:12 +0900 Subject: [PATCH 02/47] docs: align connector secret configuration --- docs/deployment/clearfolio-buyer-connector.openapi.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index cb1e90f64..2e81683c2 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -34,8 +34,11 @@ paths: operationId: submitConversionJob summary: Submit a document for asynchronous preview conversion. description: > - Requires `job:create`. The buyer gateway must add signed Clearfolio - tenant headers when `CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET` is enabled. + Requires `job:create`. The authenticated buyer gateway must strip any + untrusted inbound `X-Clearfolio-*` claim headers, then add canonical + signed Clearfolio tenant headers. Runtime verification reads + `clearfolio.tenant-claims.hmac-secret` from the config-tree mount + selected by `CLEARFOLIO_SECRET_CONFIG_DIR`. parameters: - $ref: "#/components/parameters/TenantId" - $ref: "#/components/parameters/SubjectId" From 7b7feef48d63ca44c2513d04476f080abf9e11dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 14:11:02 +0900 Subject: [PATCH 03/47] docs: document config-tree tenant claim secret --- ...2-buyer-deployment-integration-playbook.md | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md b/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md index 8a32dc847..d21a057db 100644 --- a/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md +++ b/docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md @@ -33,13 +33,23 @@ The deployment cannot yet prove: ## Runtime Profile -Use the `buyer-demo` Spring profile for a buyer sandbox: +Use the `buyer-demo` Spring profile for a buyer sandbox. Runtime key material is +loaded from a Spring Boot config-tree mount. `CLEARFOLIO_SECRET_CONFIG_DIR` +selects that mount and is not itself secret. The mounted +`clearfolio.tenant-claims.hmac-secret` file must contain at least 32 UTF-8 bytes +and must be provisioned through the deployment platform's secret manager in +shared environments. + +For a local sandbox, create an owner-readable config-tree file before startup: ```bash -mkdir -p .clearfolio/buyer-demo +umask 077 +mkdir -p .clearfolio/buyer-demo/secrets +openssl rand -base64 48 \ + > .clearfolio/buyer-demo/secrets/clearfolio.tenant-claims.hmac-secret export SPRING_PROFILES_ACTIVE=buyer-demo -export CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET="replace-with-gateway-shared-secret" +export CLEARFOLIO_SECRET_CONFIG_DIR="$PWD/.clearfolio/buyer-demo/secrets/" export CLEARFOLIO_ARTIFACT_TOKEN_SECRET="replace-with-artifact-token-secret" export CLEARFOLIO_ARTIFACT_LINK_LEDGER_PATH="$PWD/.clearfolio/buyer-demo/artifact-link-ledger.log" export CLEARFOLIO_ANALYTICS_SNAPSHOT_LEDGER_PATH="$PWD/.clearfolio/buyer-demo/kpi-snapshot-ledger.log" @@ -49,8 +59,12 @@ mvn spring-boot:run ``` The profile file is -`src/main/resources/application-buyer-demo.yml`. It uses environment variables -only; no secret value is committed. +`src/main/resources/application-buyer-demo.yml`. Non-secret operational settings +may use environment variables. Tenant-claims HMAC key material is not bound from +a runtime secret environment variable; it is read as +`clearfolio.tenant-claims.hmac-secret` from the shared config-tree import in +`application.yml`. `CLEARFOLIO_TENANT_CLAIMS_MAX_SKEW_SECONDS` remains a +non-secret runtime setting. For a Power Platform embedding test, replace `CLEARFOLIO_FRAME_ANCESTORS` with the exact buyer allowlist after the gateway hostname is known. Keep it narrow; @@ -58,8 +72,8 @@ do not use a wildcard until a security owner explicitly accepts that risk. ## Gateway Claim Contract -When `CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET` is set, every protected JSON API -call must include: +When the mounted `clearfolio.tenant-claims.hmac-secret` property is present, +every protected JSON API call must include: - `X-Clearfolio-Tenant-Id` - `X-Clearfolio-Subject-Id` @@ -96,6 +110,11 @@ gateway must send **and sign** already-canonical values: e.g. `viewer:read,job:read`. Sign what the verifier will re-derive, not the raw string. +The authenticated gateway must remove all untrusted inbound +`X-Clearfolio-*` claim headers before it maps the authenticated principal, +constructs canonical claims, signs them, and forwards the replacement header +set. Browsers and external API clients are not trusted claim issuers. + Buyer-demo permission set: ```text @@ -106,17 +125,20 @@ Production role mapping should later replace this scaffold with validated gateway or OIDC claims. Do not hand-roll JWT parsing in this service. For any environment that sets `SPRING_PROFILES_ACTIVE=production`, the service -fails startup unless `CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET` is present. The -buyer-demo profile can still run unsigned for local screenshots, but production -cannot accidentally inherit that unsigned mode. +fails startup unless the config-tree mount supplies a sufficiently strong +`clearfolio.tenant-claims.hmac-secret`. Setting only +`CLEARFOLIO_SECRET_CONFIG_DIR` without the required secret file does not enable +signed claims. The buyer-demo profile can still run unsigned for local +screenshots, but production cannot accidentally inherit that unsigned mode. ## Integration Flow 1. Buyer browser, Power Platform, or internal workflow authenticates at the buyer-controlled gateway. -2. Gateway maps the principal to Clearfolio tenant id, subject id, and - permissions. -3. Gateway signs the Clearfolio headers and forwards requests to +2. Gateway strips untrusted inbound Clearfolio claim headers, maps the principal + to Clearfolio tenant id, subject id, and permissions, and canonicalizes the + mapped values. +3. Gateway signs the canonical Clearfolio headers and forwards requests to `POST /api/v1/convert/jobs`, status, viewer bootstrap, retry, artifact-link, and analytics APIs. 4. Clearfolio verifies the signed headers, enforces permissions, and hides @@ -240,8 +262,9 @@ The buyer sandbox should not be promoted to production until these gates close: - buyer-release license-policy evidence remains green with `--require-no-review`, attribution drift check remains green, and final legal release review is obtained; -- `SPRING_PROFILES_ACTIVE=production` starts only with configured signed tenant - claims and later replaces the scaffold with validated OIDC/JWT claims; +- `SPRING_PROFILES_ACTIVE=production` starts only when the config-tree mount + contains a strong `clearfolio.tenant-claims.hmac-secret`, and later replaces + the scaffold with validated OIDC/JWT claims; - validated gateway or OIDC JWT issuer, audience, expiry, key rotation, and role mapping; - durable conversion job repository with persisted state transitions; From f4ae8dd695afe1dd41decbc7e6b2a11d0ee5e461 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 14:12:04 +0900 Subject: [PATCH 04/47] docs: align agent secret guidance --- AGENTS.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae0a7b55c..baeaa0071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,15 +82,18 @@ Codex, Cursor, opencode, …) working in this repo. - Reference implementation: xtrmLLMBatchPython's pgcrypto-encrypted Postgres credential registry (`get_credential(name)`). Reuse that pattern (a DB-backed KV is fine) unless a dedicated KV is adopted. -- **This repo applies** — it is a Spring Boot service with real runtime secrets - (artifact-token HMAC secret, tenant-claims HMAC secret). **Known deviation to - migrate:** those secrets are currently injected straight from env via Spring - placeholders in `application-buyer-demo.yml` - (`clearfolio.artifact-token.secret: ${CLEARFOLIO_ARTIFACT_TOKEN_SECRET:}`, - `clearfolio.tenant-claims.hmac-secret: ${CLEARFOLIO_TENANT_CLAIMS_HMAC_SECRET:}`, - consumed by `ArtifactLinkService` / `TenantAccessService`). Move these to a - KV-backed lookup so env is only the bootstrap transport into the KV. New - secrets/credentials must go through the KV from the start, not new env reads. +- **This repo applies** — it is a Spring Boot service with real runtime secrets. + The tenant-claims HMAC secret is loaded as + `clearfolio.tenant-claims.hmac-secret` from the Spring config-tree credential + mount selected by the non-secret `CLEARFOLIO_SECRET_CONFIG_DIR` bootstrap + setting; do not restore direct runtime environment binding for that key. + **Known deviation to migrate:** the artifact-token HMAC secret is still + injected directly from an environment placeholder in + `application-buyer-demo.yml` + (`clearfolio.artifact-token.secret: ${CLEARFOLIO_ARTIFACT_TOKEN_SECRET:}`), + consumed by `ArtifactLinkService`. Move it to a KV-backed lookup so env is + only the bootstrap transport into the KV. New secrets/credentials must go + through the KV from the start, not new env reads. ### Code exploration From ea57a472e960f6d1a0f37b175abd5d37fda70a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:17:48 +0900 Subject: [PATCH 05/47] test: require tenant-scoped administrative listing --- .../viewer/controller/AdminControllerTest.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index 5cd95f2bb..d65b8ddf1 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -104,11 +104,11 @@ void missingReadPermissionIsDeniedBeforeServiceAccess() { } @Test - void listReturnsOnlyTenantOwnedJobsAndAppliesDeadLetterFilter() { + void listUsesTenantScopedServiceAndAppliesDeadLetterFilter() { ConversionJob deadLettered = job(TENANT_ID, "dead.pdf", true); ConversionJob ready = job(TENANT_ID, "ready.pdf", false); - ConversionJob otherTenant = job("tenant-south", "secret.pdf", false); - when(conversionService.getAllJobs()).thenReturn(List.of(deadLettered, ready, otherTenant)); + when(conversionService.getJobsForTenant(any(TenantContext.class))) + .thenReturn(List.of(deadLettered, ready)); HttpHeaders headers = signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_READ); requestJobs(headers) @@ -130,18 +130,24 @@ void listReturnsOnlyTenantOwnedJobsAndAppliesDeadLetterFilter() { .jsonPath("$.jobs.length()").isEqualTo(1) .jsonPath("$.jobs[0].fileName").isEqualTo("ready.pdf"); - verify(conversionService, times(3)).getAllJobs(); + verify(conversionService, times(3)).getJobsForTenant( + argThat(context -> TENANT_ID.equals(context.tenantId())) + ); + verify(conversionService, never()).getAllJobs(); } @Test void listServiceFailureReturnsGenericInternalError() { - when(conversionService.getAllJobs()).thenThrow(new IllegalStateException("repository unavailable")); + when(conversionService.getJobsForTenant(any(TenantContext.class))) + .thenThrow(new IllegalStateException("repository unavailable")); requestJobs(signedHeaders(TENANT_ID, SUBJECT_ID, TenantPermissions.ADMIN_READ)) .expectStatus().is5xxServerError() .expectBody() .jsonPath("$.errorCode").isEqualTo("INTERNAL_ERROR") .jsonPath("$.message").isEqualTo("Unexpected error"); + + verify(conversionService, never()).getAllJobs(); } @Test From 885bc8584ccbd97b7180cc6836154efaf9ddd60a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:18:37 +0900 Subject: [PATCH 06/47] test: define tenant-scoped repository contract --- ...onversionJobRepositoryTenantScopeTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java new file mode 100644 index 000000000..6c0f5d042 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java @@ -0,0 +1,61 @@ +package com.clearfolio.viewer.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Proves that administrative listing can be bounded at the repository layer + * before another tenant's job objects reach the service or controller. + */ +class InMemoryConversionJobRepositoryTenantScopeTest { + + @Test + void returnsOnlyJobsOwnedByTheRequestedTenant() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob northFirst = job("tenant-north", "north-first.pdf"); + ConversionJob northSecond = job("tenant-north", "north-second.pdf"); + ConversionJob southSecret = job("tenant-south", "south-secret.pdf"); + repository.save(northFirst); + repository.save(southSecret); + repository.save(northSecond); + + List tenantJobs = repository.findAllByTenantId("tenant-north"); + + assertEquals(2, tenantJobs.size()); + assertEquals( + Set.of(northFirst.getJobId(), northSecond.getJobId()), + tenantJobs.stream().map(ConversionJob::getJobId).collect(java.util.stream.Collectors.toSet()) + ); + assertTrue(tenantJobs.stream().noneMatch(job -> "tenant-south".equals(job.getTenantId()))); + } + + @Test + void nullOrBlankTenantIdentifiersFailClosed() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + repository.save(job("tenant-north", "north.pdf")); + + assertTrue(repository.findAllByTenantId(null).isEmpty()); + assertTrue(repository.findAllByTenantId(" ").isEmpty()); + } + + private static ConversionJob job(String tenantId, String fileName) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + "owner", + fileName, + "application/pdf", + UUID.randomUUID().toString(), + 100L, + 3 + ); + } +} From b87f28ea1d6b91405da853198fe28fa7b8cdb41a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:19:23 +0900 Subject: [PATCH 07/47] test: define tenant-scoped service listing contract --- ...tScopedAdministrativeListContractTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/TenantScopedAdministrativeListContractTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedAdministrativeListContractTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedAdministrativeListContractTest.java new file mode 100644 index 000000000..c9965528f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedAdministrativeListContractTest.java @@ -0,0 +1,87 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Defines the service-layer boundary for tenant-scoped administrative job lists. + */ +class TenantScopedAdministrativeListContractTest { + + @Test + void delegatesOnlyTheAuthenticatedTenantIdentifierToTheRepository() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionJob tenantJob = job("tenant-north"); + when(repository.findAllByTenantId("tenant-north")).thenReturn(List.of(tenantJob)); + DefaultDocumentConversionService service = service(repository); + TenantContext context = new TenantContext( + "tenant-north", + "administrator", + Set.of("admin:read") + ); + + Iterable result = service.getJobsForTenant(context); + List jobs = java.util.stream.StreamSupport + .stream(result.spliterator(), false) + .toList(); + + assertEquals(1, jobs.size()); + assertSame(tenantJob, jobs.getFirst()); + verify(repository).findAllByTenantId("tenant-north"); + verify(repository, never()).findAll(); + } + + @Test + void absentTenantContextFailsClosedBeforeRepositoryAccess() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + DefaultDocumentConversionService service = service(repository); + + Iterable result = service.getJobsForTenant(null); + + assertTrue(java.util.stream.StreamSupport.stream(result.spliterator(), false).findAny().isEmpty()); + verifyNoInteractions(repository); + } + + private static DefaultDocumentConversionService service(ConversionJobRepository repository) { + return new DefaultDocumentConversionService( + repository, + file -> { + }, + jobId -> { + }, + new InMemoryArtifactStore(), + new ConversionProperties() + ); + } + + private static ConversionJob job(String tenantId) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + "owner", + "contract.pdf", + "application/pdf", + UUID.randomUUID().toString(), + 100L, + 3 + ); + } +} From 42463f93a25f556ebdcfefff348790dbab7f5140 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:21:10 +0900 Subject: [PATCH 08/47] feat: add fail-closed tenant-scoped job query --- .../repository/ConversionJobRepository.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java index 03c001804..b9caae403 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java @@ -76,6 +76,22 @@ default Optional findByTenantAndId(String tenantId, UUID jobId) { */ List findAll(); + /** + * Returns only jobs owned by the supplied tenant. + * + *

The default deliberately returns an empty result rather than falling + * back to {@link #findAll()}. Every durable repository adapter must + * implement a tenant predicate in its storage query before administrative + * callers may receive job objects. + * + * @param tenantId authenticated tenant identifier + * @return tenant-owned jobs, or an empty list until the adapter implements + * the scoped query + */ + default List findAllByTenantId(String tenantId) { + return List.of(); + } + /** * Finds jobs that should be considered for recovery after worker restart. * From 9eb342f171f7525dad8c58733b45dd6a6cbb47d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:22:59 +0900 Subject: [PATCH 09/47] feat: enforce tenant predicate before listing jobs --- .../InMemoryConversionJobRepository.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 7d7ee6384..2fa4b7830 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -123,6 +123,20 @@ public List findAll() { return List.copyOf(jobs.values()); } + /** + * {@inheritDoc} + */ + @Override + public List findAllByTenantId(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + return List.of(); + } + String normalizedTenantId = tenantId.strip(); + return jobs.values().stream() + .filter(job -> job.belongsToTenant(normalizedTenantId)) + .toList(); + } + /** * {@inheritDoc} */ From 1689e09a4a3cfeb3661890127055da551e2e74e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:24:27 +0900 Subject: [PATCH 10/47] feat: add fail-closed tenant-scoped service listing --- .../service/DocumentConversionService.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java index 6ff0aad4b..f9bfa56c8 100644 --- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java @@ -1,5 +1,6 @@ package com.clearfolio.viewer.service; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -119,6 +120,21 @@ default boolean deleteJob(UUID jobId, TenantContext tenantContext) { */ void deleteJob(UUID jobId); + /** + * Returns only jobs visible to the authenticated tenant context. + * + *

The default is intentionally empty. Concrete production services must + * override this method and delegate to a repository query that applies the + * tenant predicate before job objects cross the persistence boundary.

+ * + * @param tenantContext authenticated tenant and subject claims + * @return tenant-owned jobs, or an empty iterable when scoped listing is not + * implemented or the context is absent + */ + default Iterable getJobsForTenant(TenantContext tenantContext) { + return List.of(); + } + /** * Returns all registered conversion jobs. * From 7e3c0b7e8d7c48e758505671928beed7b84205ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 16:27:31 +0900 Subject: [PATCH 11/47] feat: delegate administrative lists to tenant query --- .../service/DefaultDocumentConversionService.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index df3dd13e3..2c129e13d 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -5,6 +5,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; +import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.UUID; @@ -300,6 +301,17 @@ private RetryDeadLetterResult retryExistingJob( return RetryDeadLetterResult.ACCEPTED; } + /** + * {@inheritDoc} + */ + @Override + public Iterable getJobsForTenant(TenantContext tenantContext) { + if (tenantContext == null) { + return List.of(); + } + return repository.findAllByTenantId(tenantContext.tenantId()); + } + /** * {@inheritDoc} */ @@ -388,7 +400,6 @@ private String contentHash(MultipartFile file) { } byte[] raw = digest.digest(); - // Reused HexFormat for performance return HEX_FORMAT.formatHex(raw); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 digest unavailable", ex); From c3358629e9361a946a4b7106a5efaf141bf946b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:05:09 +0900 Subject: [PATCH 12/47] fix(security): enforce tenant scope before admin list materialization --- .../com/clearfolio/viewer/controller/AdminController.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 872f0a66e..91872ceb1 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -81,11 +81,10 @@ public AdminJobListResponse getAllJobs( try { List filtered = new ArrayList<>(); - for (ConversionJob job : conversionService.getAllJobs()) { - boolean tenantOwned = job.belongsToTenant(context.tenantId()); + for (ConversionJob job : conversionService.getJobsForTenant(context)) { boolean deadLetterMatches = deadLettered == null || job.isDeadLettered() == deadLettered; - if (tenantOwned && deadLetterMatches) { + if (deadLetterMatches) { filtered.add(job); } } From d35578206bba293e9aa448499f9b38adf2a545e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:36:29 +0900 Subject: [PATCH 13/47] test(security): require tenant-aware defaults to fail closed --- .../TenantScopedServiceDefaultsTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/TenantScopedServiceDefaultsTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedServiceDefaultsTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedServiceDefaultsTest.java new file mode 100644 index 000000000..35f078bff --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedServiceDefaultsTest.java @@ -0,0 +1,92 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies that compatibility-only service adapters cannot perform privileged + * mutations through unscoped legacy methods. + * + *

Tenant-aware interface defaults are security boundaries. An adapter that + * implements only the historical global methods must fail closed until it + * supplies an atomic tenant-scoped mutation implementation.

+ */ +class TenantScopedServiceDefaultsTest { + + private static final TenantContext TENANT_CONTEXT = new TenantContext( + "tenant-north", + "operator-north", + Set.of("admin:write") + ); + + @Test + void tenantAwareDeleteDefaultDoesNotReadOrInvokeLegacyMutation() { + CompatibilityOnlyService service = new CompatibilityOnlyService(); + + boolean deleted = service.deleteJob(UUID.randomUUID(), TENANT_CONTEXT); + + assertFalse(deleted); + assertEquals(0, service.getJobCalls); + assertEquals(0, service.legacyDeleteCalls); + } + + @Test + void tenantAwareRetryDefaultDoesNotReadOrInvokeLegacyMutation() { + CompatibilityOnlyService service = new CompatibilityOnlyService(); + + RetryDeadLetterResult result = service.retryDeadLettered( + UUID.randomUUID(), + TENANT_CONTEXT, + "admin-v1:0123456789abcdef0123456789abcdef" + ); + + assertEquals(RetryDeadLetterResult.NOT_FOUND, result); + assertEquals(0, service.getJobCalls); + assertEquals(0, service.legacyRetryCalls); + } + + private static final class CompatibilityOnlyService implements DocumentConversionService { + + private int getJobCalls; + private int legacyDeleteCalls; + private int legacyRetryCalls; + + @Override + public UUID submit(MultipartFile file) { + throw new UnsupportedOperationException("submission is outside this contract test"); + } + + @Override + public Optional getJob(UUID jobId) { + getJobCalls += 1; + return Optional.empty(); + } + + @Override + public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { + legacyRetryCalls += 1; + return RetryDeadLetterResult.ACCEPTED; + } + + @Override + public void deleteJob(UUID jobId) { + legacyDeleteCalls += 1; + } + + @Override + public Iterable getAllJobs() { + return List.of(); + } + } +} From 8fbf2d118b94aff9f0a4657aaf098217ffa4fca9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:36:52 +0900 Subject: [PATCH 14/47] fix(security): fail closed for unscoped adapter mutations --- .../service/DocumentConversionService.java | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java index f9bfa56c8..b43b13e08 100644 --- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java @@ -64,53 +64,39 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T /** * Retries a dead-lettered conversion job owned by the supplied tenant. * - *

The default implementation preserves compatibility for adapters that - * have not yet implemented an atomic tenant-aware transition. Durable - * implementations should override this method so ownership and transition - * are enforced within one persistence boundary.

+ *

The default fails closed without reading a job or invoking the legacy + * unscoped retry method. A concrete service must override this method and + * enforce tenant selection and the retry transition within one persistence + * boundary before an administrative caller can receive an accepted result.

* * @param jobId conversion job identifier * @param tenantContext tenant and subject claims for the retry request * @param operatorId privacy-safe operator fingerprint that triggered retry - * @return accepted, not-found, or not-eligible retry outcome + * @return not-found until the implementation supplies an atomic tenant-aware + * retry operation */ default RetryDeadLetterResult retryDeadLettered( UUID jobId, TenantContext tenantContext, String operatorId ) { - if (tenantContext == null) { - return RetryDeadLetterResult.NOT_FOUND; - } - - Optional job = getJob(jobId); - if (job.isEmpty() || !job.get().belongsToTenant(tenantContext.tenantId())) { - return RetryDeadLetterResult.NOT_FOUND; - } - - return retryDeadLettered(jobId, operatorId); + return RetryDeadLetterResult.NOT_FOUND; } /** * Deletes a conversion job owned by the supplied tenant context. * + *

The default fails closed without reading a job or invoking the legacy + * unscoped delete method. A concrete service must override this method and + * enforce tenant selection and deletion within one persistence boundary.

+ * * @param jobId conversion job identifier * @param tenantContext tenant and subject claims for the delete request - * @return true when an owned job was deleted; false when it was missing or - * belonged to another tenant + * @return false until the implementation supplies an atomic tenant-aware + * delete operation */ default boolean deleteJob(UUID jobId, TenantContext tenantContext) { - if (tenantContext == null) { - return false; - } - - Optional job = getJob(jobId); - if (job.isEmpty() || !job.get().belongsToTenant(tenantContext.tenantId())) { - return false; - } - - deleteJob(jobId); - return true; + return false; } /** From e29b39fb4269a0b8edafe057a687bbd1f7a5caad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:37:25 +0900 Subject: [PATCH 15/47] docs(security): define fail-closed adapter mutation defaults --- docs/security/2026-08-05-administrative-authorization.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 57004e575..bef4c92da 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -31,7 +31,7 @@ Every endpoint applies the same fail-closed sequence: List responses filter the complete repository result to the verified tenant before applying the optional dead-letter status filter. Delete and retry do not perform controller-level read-then-write authorization. Their service contracts receive the verified tenant context and enforce ownership at the mutation boundary, so non-HTTP callers cannot reach an unscoped administrative mutation by bypassing the controller. -The legacy two-argument retry method remains only as a compatibility contract for non-administrative adapters. Administrative HTTP flows call the tenant-aware three-argument method. The durable implementation performs the tenant-scoped selection before invoking the shared state transition and therefore does not rely on a controller-side ownership check. +The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware interface defaults fail closed by returning `false` or `NOT_FOUND` without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's durable implementation supplies those scoped overrides. ## Audit evidence @@ -56,6 +56,7 @@ Automated tests must exercise the real signed-claim verifier and prove: - list results contain only tenant-owned jobs for all dead-letter filter states; - missing and cross-tenant delete/retry targets produce indistinguishable not-found responses; - delete and retry cross a tenant-aware service boundary without a separate controller lookup or unscoped administrative mutation call; +- compatibility-only service adapters cannot reach `getJob`, legacy delete, or legacy retry through the tenant-aware interface defaults; - the durable retry service rejects null, missing, and cross-tenant contexts without state transition or worker enqueue; - accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; - not-found, not-eligible, repository failure, deletion failure, and retry failure paths return stable non-leaking responses; From 5bf134e606c009584a8fc78a7a45510ad41e65fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:38:16 +0900 Subject: [PATCH 16/47] docs(changelog): record fail-closed tenant mutation defaults --- CHANGELOG.md | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428a9a0d9..199279677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,35 +1,44 @@ +# Changelog + ## [Unreleased] + ### Added + - **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. +- **관리자용 단건 작업 삭제 및 재시도 API 추가** + - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. + - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. +- **비동기 버튼 로딩 피드백 및 상태 복원 개선** + - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. ### Changed + - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +- CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. +- Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. +- Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. ### Security + - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. +- 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. - 관리자 API에 서명된 tenant claim 검증, `admin:read`/`admin:write` 최소 권한, tenant 소유권 검사를 적용했습니다. 누락 및 cross-tenant 객체는 동일한 not-found 응답으로 은폐합니다. -- 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해, controller 우회 호출에서도 소유권 검사가 적용되고 read-then-mutate 간격이 생기지 않도록 했습니다. +- 관리자 목록 조회는 repository 경계에서 tenant predicate를 적용한 뒤 선택적 dead-letter 필터를 처리하도록 변경했습니다. +- 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해 controller 우회 호출에서도 소유권 검사가 적용되도록 했습니다. +- tenant-aware service 기본 구현은 global lookup 또는 legacy mutation을 호출하지 않고 `false`/`NOT_FOUND`로 실패 종료합니다. 모듈형 대체 adapter는 원자적 tenant-scoped mutation을 명시적으로 구현해야 관리자 작업을 성공시킬 수 있습니다. - 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다. - buyer-demo profile의 tenant-claims HMAC secret 환경변수 직접 바인딩을 제거하고 공통 Spring config-tree secret mount에서 읽도록 변경했습니다. -# Changelog - -## [Unreleased] - -### 추가된 기능 (Added) -- **관리자용 단건 작업 삭제 및 재시도 API 추가** - - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - -- **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Fixed +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -45,9 +54,11 @@ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) + - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. @@ -56,6 +67,3 @@ - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. - -### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 From 8d717dc30470ec5159fd4f390f37573525fcaa39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:41:23 +0900 Subject: [PATCH 17/47] test(security): align retry contract with fail-closed defaults --- .../service/TenantScopedRetryContractTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java index f6f165d46..1a30be128 100644 --- a/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedRetryContractTest.java @@ -1,6 +1,7 @@ package com.clearfolio.viewer.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; @@ -26,9 +27,10 @@ class TenantScopedRetryContractTest { @Test - void interfaceDefaultRejectsUnownedJobsBeforeLegacyMutation() { + void interfaceDefaultFailsClosedWithoutLookupOrLegacyMutation() { UUID jobId = UUID.randomUUID(); ConversionJob job = job(jobId, "tenant-a", "default-contract"); + AtomicInteger getJobCalls = new AtomicInteger(); AtomicReference retriedJobId = new AtomicReference<>(); AtomicReference retriedOperatorId = new AtomicReference<>(); DocumentConversionService service = new DocumentConversionService() { @@ -39,6 +41,7 @@ public UUID submit(MultipartFile file) { @Override public Optional getJob(UUID requestedJobId) { + getJobCalls.incrementAndGet(); return jobId.equals(requestedJobId) ? Optional.of(job) : Optional.empty(); } @@ -74,11 +77,12 @@ public Iterable getAllJobs() { service.retryDeadLettered(jobId, tenantB, "operator-cross-tenant") ); assertEquals( - RetryDeadLetterResult.ACCEPTED, + RetryDeadLetterResult.NOT_FOUND, service.retryDeadLettered(jobId, tenantA, "operator-owned") ); - assertEquals(jobId, retriedJobId.get()); - assertEquals("operator-owned", retriedOperatorId.get()); + assertEquals(0, getJobCalls.get()); + assertNull(retriedJobId.get()); + assertNull(retriedOperatorId.get()); } @Test From 5409117bf3fc9a1dba1b0d1e916a4ab27e1c587a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:42:58 +0900 Subject: [PATCH 18/47] test(security): require atomic tenant-scoped mutations --- ...enantScopedAtomicMutationBoundaryTest.java | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java new file mode 100644 index 000000000..36db5c967 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java @@ -0,0 +1,170 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobRepository; +import com.clearfolio.viewer.repository.ConversionJobStateStore; +import com.clearfolio.viewer.repository.ConversionJobStateStore.TenantRetryOutcome; + +/** + * Verifies that privileged mutations use atomic tenant-scoped persistence + * operations instead of read-then-unscoped-mutate sequences. + */ +class TenantScopedAtomicMutationBoundaryTest { + + private static final TenantContext TENANT_CONTEXT = new TenantContext( + "tenant-north", + "operator-north", + Set.of("admin:write") + ); + + @Test + void tenantAwareDeleteUsesScopedRepositoryDeletionBeforeArtifactCleanup() throws Exception { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionJobStateStore stateStore = mock(ConversionJobStateStore.class); + ArtifactStore artifactStore = mock(ArtifactStore.class); + UUID missingId = UUID.randomUUID(); + UUID ownedId = UUID.randomUUID(); + when(repository.deleteByTenantAndId(TENANT_CONTEXT.tenantId(), missingId)) + .thenReturn(false); + when(repository.deleteByTenantAndId(TENANT_CONTEXT.tenantId(), ownedId)) + .thenReturn(true); + DefaultDocumentConversionService service = service(repository, stateStore, artifactStore, mock(ConversionWorker.class)); + + assertFalse(service.deleteJob(missingId, TENANT_CONTEXT)); + assertTrue(service.deleteJob(ownedId, TENANT_CONTEXT)); + + verify(repository).deleteByTenantAndId(TENANT_CONTEXT.tenantId(), missingId); + verify(repository).deleteByTenantAndId(TENANT_CONTEXT.tenantId(), ownedId); + verify(repository, never()).findByTenantAndId(anyString(), any(UUID.class)); + verify(repository, never()).deleteById(any(UUID.class)); + verify(artifactStore, never()).deletePdf(missingId); + verify(artifactStore).deletePdf(ownedId); + } + + @Test + void tenantAwareRetryMapsOneAtomicStateStoreOutcomeAndEnqueuesOnlyAcceptedJobs() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionJobStateStore stateStore = mock(ConversionJobStateStore.class); + ConversionWorker worker = mock(ConversionWorker.class); + UUID missingId = UUID.randomUUID(); + UUID activeId = UUID.randomUUID(); + UUID acceptedId = UUID.randomUUID(); + String actorFingerprint = "admin-v1:0123456789abcdef0123456789abcdef"; + when(stateStore.retryDeadLetteredForTenant( + TENANT_CONTEXT.tenantId(), + missingId, + actorFingerprint + )).thenReturn(TenantRetryOutcome.NOT_FOUND); + when(stateStore.retryDeadLetteredForTenant( + TENANT_CONTEXT.tenantId(), + activeId, + actorFingerprint + )).thenReturn(TenantRetryOutcome.NOT_ELIGIBLE); + when(stateStore.retryDeadLetteredForTenant( + TENANT_CONTEXT.tenantId(), + acceptedId, + actorFingerprint + )).thenReturn(TenantRetryOutcome.ACCEPTED); + DefaultDocumentConversionService service = service( + repository, + stateStore, + mock(ArtifactStore.class), + worker + ); + + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(missingId, TENANT_CONTEXT, actorFingerprint) + ); + assertEquals( + RetryDeadLetterResult.NOT_ELIGIBLE, + service.retryDeadLettered(activeId, TENANT_CONTEXT, actorFingerprint) + ); + assertEquals( + RetryDeadLetterResult.ACCEPTED, + service.retryDeadLettered(acceptedId, TENANT_CONTEXT, actorFingerprint) + ); + + verify(repository, never()).findByTenantAndId(anyString(), any(UUID.class)); + verify(stateStore, never()).retryDeadLettered(any(UUID.class), anyString()); + verify(worker, never()).enqueue(missingId); + verify(worker, never()).enqueue(activeId); + verify(worker).enqueue(acceptedId); + } + + @Test + void compatibilityStateStoreDefaultDoesNotInvokeLegacyRetry() { + AtomicInteger legacyRetryCalls = new AtomicInteger(); + ConversionJobStateStore stateStore = new ConversionJobStateStore() { + @Override + public Optional claimForProcessing(UUID jobId, Instant now) { + return Optional.empty(); + } + + @Override + public void scheduleRetry(UUID jobId, String message, Instant retryAt) { + } + + @Override + public void markSucceeded(UUID jobId, String resourcePath, String message) { + } + + @Override + public void markDeadLettered(UUID jobId, String message) { + } + + @Override + public boolean retryDeadLettered(UUID jobId, String operatorId) { + legacyRetryCalls.incrementAndGet(); + return true; + } + }; + + TenantRetryOutcome outcome = stateStore.retryDeadLetteredForTenant( + TENANT_CONTEXT.tenantId(), + UUID.randomUUID(), + "actor" + ); + + assertEquals(TenantRetryOutcome.NOT_FOUND, outcome); + assertEquals(0, legacyRetryCalls.get()); + } + + private static DefaultDocumentConversionService service( + ConversionJobRepository repository, + ConversionJobStateStore stateStore, + ArtifactStore artifactStore, + ConversionWorker worker + ) { + return new DefaultDocumentConversionService( + repository, + stateStore, + mock(DocumentValidationService.class), + worker, + artifactStore, + new ConversionProperties() + ); + } +} From 274eed49a8fc9990103deceed98babae0f45fd21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:43:27 +0900 Subject: [PATCH 19/47] feat(security): add atomic tenant-scoped delete contract --- .../repository/ConversionJobRepository.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java index b9caae403..0620d232f 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java @@ -82,7 +82,7 @@ default Optional findByTenantAndId(String tenantId, UUID jobId) { *

The default deliberately returns an empty result rather than falling * back to {@link #findAll()}. Every durable repository adapter must * implement a tenant predicate in its storage query before administrative - * callers may receive job objects. + * callers may receive job objects.

* * @param tenantId authenticated tenant identifier * @return tenant-owned jobs, or an empty list until the adapter implements @@ -117,6 +117,21 @@ default List findRecoverableJobs(Instant now, Instant staleProces */ FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate); + /** + * Deletes one job only when it is owned by the supplied tenant. + * + *

The default fails closed without calling the global delete method. + * Durable repository adapters must override this method with one atomic + * tenant-predicate delete operation.

+ * + * @param tenantId authenticated tenant identifier + * @param jobId conversion job identifier + * @return true only when an owned job was deleted + */ + default boolean deleteByTenantAndId(String tenantId, UUID jobId) { + return false; + } + /** * Deletes a conversion job by identifier. * From cf68487c6322e751c89d960aded17cc20edb674e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:43:43 +0900 Subject: [PATCH 20/47] feat(security): add atomic tenant-scoped retry contract --- .../repository/ConversionJobStateStore.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobStateStore.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobStateStore.java index 6cc8dd97d..ef032e374 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobStateStore.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobStateStore.java @@ -11,6 +11,18 @@ */ public interface ConversionJobStateStore { + /** + * Outcomes from one atomic tenant-scoped dead-letter retry attempt. + */ + enum TenantRetryOutcome { + /** An owned dead-lettered job moved back to submitted state. */ + ACCEPTED, + /** The job was absent or was owned by a different tenant. */ + NOT_FOUND, + /** The owned job existed but was not eligible for retry. */ + NOT_ELIGIBLE + } + /** * Claims a ready job for processing. * @@ -54,4 +66,24 @@ public interface ConversionJobStateStore { * @return true when the retry transition succeeds */ boolean retryDeadLettered(UUID jobId, String operatorId); + + /** + * Retries one dead-lettered job only when it belongs to the supplied tenant. + * + *

The default fails closed without invoking the legacy unscoped retry. + * Durable state-store adapters must override this method with one atomic + * tenant selection and state transition.

+ * + * @param tenantId authenticated tenant identifier + * @param jobId conversion job identifier + * @param operatorId privacy-safe operator fingerprint + * @return accepted, concealed not-found, or not-eligible outcome + */ + default TenantRetryOutcome retryDeadLetteredForTenant( + String tenantId, + UUID jobId, + String operatorId + ) { + return TenantRetryOutcome.NOT_FOUND; + } } From c63d3bc2b5504da27e9fd786b4e753e2ebb85b16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:44:41 +0900 Subject: [PATCH 21/47] fix(security): make in-memory tenant mutations atomic --- .../InMemoryConversionJobRepository.java | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 2fa4b7830..06f25759c 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -141,11 +141,35 @@ public List findAllByTenantId(String tenantId) { * {@inheritDoc} */ @Override - public void deleteById(UUID jobId) { - ConversionJob removed = jobs.remove(jobId); - if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) { - jobsByTenantAndContentHash.remove(contentKey(removed.getTenantId(), removed.getContentHash()), jobId); + public boolean deleteByTenantAndId(String tenantId, UUID jobId) { + if (tenantId == null || tenantId.isBlank()) { + return false; + } + + String normalizedTenantId = tenantId.strip(); + AtomicReference removed = new AtomicReference<>(); + jobs.computeIfPresent(jobId, (ignored, existing) -> { + if (!existing.belongsToTenant(normalizedTenantId)) { + return existing; + } + removed.set(existing); + return null; + }); + + ConversionJob deletedJob = removed.get(); + if (deletedJob == null) { + return false; } + removeContentIndex(deletedJob); + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public void deleteById(UUID jobId) { + removeContentIndex(jobs.remove(jobId)); } /** @@ -250,6 +274,41 @@ public boolean retryDeadLettered(UUID jobId, String operatorId) { return true; } + /** + * {@inheritDoc} + */ + @Override + public TenantRetryOutcome retryDeadLetteredForTenant( + String tenantId, + UUID jobId, + String operatorId + ) { + if (tenantId == null || tenantId.isBlank()) { + return TenantRetryOutcome.NOT_FOUND; + } + + String normalizedTenantId = tenantId.strip(); + AtomicReference outcome = new AtomicReference<>( + TenantRetryOutcome.NOT_FOUND + ); + jobs.computeIfPresent(jobId, (ignored, existing) -> { + if (!existing.belongsToTenant(normalizedTenantId)) { + return existing; + } + + ConversionJobStatus statusBefore = existing.getStatus(); + if (!existing.retryDeadLetteredToSubmitted(operatorId)) { + outcome.set(TenantRetryOutcome.NOT_ELIGIBLE); + return existing; + } + + appendLifecycleEvent(existing, EVENT_RETRY_ACCEPTED, statusBefore); + outcome.set(TenantRetryOutcome.ACCEPTED); + return existing; + }); + return outcome.get(); + } + private String contentKey(String tenantId, String contentHash) { return normalizeTenantId(tenantId) + "\u001f" + contentHash; } @@ -258,6 +317,15 @@ private String normalizeTenantId(String tenantId) { return tenantId == null || tenantId.isBlank() ? "buyer-demo" : tenantId.strip(); } + private void removeContentIndex(ConversionJob removed) { + if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) { + jobsByTenantAndContentHash.remove( + contentKey(removed.getTenantId(), removed.getContentHash()), + removed.getJobId() + ); + } + } + private void appendLifecycleEvent( ConversionJob job, String eventType, From e5ea5722ce52946b12dd6f11967d6094f651c834 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:45:53 +0900 Subject: [PATCH 22/47] fix(security): route admin mutations through atomic tenant scope --- .../DefaultDocumentConversionService.java | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index 2c129e13d..105fa73fc 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -21,6 +21,7 @@ import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.repository.ConversionJobRepository; import com.clearfolio.viewer.repository.ConversionJobStateStore; +import com.clearfolio.viewer.repository.ConversionJobStateStore.TenantRetryOutcome; import com.clearfolio.viewer.repository.RepositoryBackedConversionJobStateStore; /** @@ -226,12 +227,12 @@ public boolean deleteJob(UUID jobId, TenantContext tenantContext) { return false; } - Optional job = repository.findByTenantAndId(tenantContext.tenantId(), jobId); - if (job.isEmpty()) { + boolean deleted = repository.deleteByTenantAndId(tenantContext.tenantId(), jobId); + if (!deleted) { return false; } - deleteJob(job.get().getJobId()); + deleteArtifact(jobId); return true; } @@ -240,11 +241,7 @@ public boolean deleteJob(UUID jobId, TenantContext tenantContext) { */ @Override public void deleteJob(UUID jobId) { - try { - artifactStore.deletePdf(jobId); - } catch (Exception ex) { - log.warn("Failed to delete artifact for job {}", jobId, ex); - } + deleteArtifact(jobId); repository.deleteById(jobId); } @@ -261,11 +258,19 @@ public RetryDeadLetterResult retryDeadLettered( return RetryDeadLetterResult.NOT_FOUND; } - Optional existing = repository.findByTenantAndId( + TenantRetryOutcome outcome = stateStore.retryDeadLetteredForTenant( tenantContext.tenantId(), - jobId + jobId, + operatorId ); - return retryExistingJob(existing, operatorId); + return switch (outcome) { + case ACCEPTED -> { + conversionWorker.enqueue(jobId); + yield RetryDeadLetterResult.ACCEPTED; + } + case NOT_FOUND -> RetryDeadLetterResult.NOT_FOUND; + case NOT_ELIGIBLE -> RetryDeadLetterResult.NOT_ELIGIBLE; + }; } /** @@ -277,11 +282,13 @@ public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { } /** - * Applies the retry transition to a job that has already been selected by - * the caller's required authorization scope. + * Applies the legacy unscoped retry transition to a selected job. + * + *

Administrative callers do not use this compatibility path. They use + * the atomic tenant-aware state-store operation instead.

* - * @param existing selected conversion job, or empty when no authorized job exists - * @param operatorId privacy-safe operator fingerprint recorded by the state store + * @param existing selected conversion job, or empty when no job exists + * @param operatorId operator identifier recorded by the state store * @return accepted, not-found, or not-eligible retry result */ private RetryDeadLetterResult retryExistingJob( @@ -320,6 +327,14 @@ public Iterable getAllJobs() { return repository.findAll(); } + private void deleteArtifact(UUID jobId) { + try { + artifactStore.deletePdf(jobId); + } catch (Exception ex) { + log.warn("Failed to delete artifact for job {}", jobId, ex); + } + } + private void seedPdfPassthroughArtifact(ConversionJob job, MultipartFile file) { if (!declaresPdfSource(job.getOriginalFileName(), job.getContentType())) { return; From 62571c310d5e6cdc64bb2ec45c0abe08d8b9876d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:46:47 +0900 Subject: [PATCH 23/47] test(security): cover atomic in-memory tenant mutations --- ...onversionJobRepositoryTenantScopeTest.java | 114 +++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java index 6c0f5d042..9bacb4b38 100644 --- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java @@ -1,6 +1,7 @@ package com.clearfolio.viewer.repository; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -10,10 +11,12 @@ import org.junit.jupiter.api.Test; import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.model.ConversionJobStatus; +import com.clearfolio.viewer.repository.ConversionJobStateStore.TenantRetryOutcome; /** - * Proves that administrative listing can be bounded at the repository layer - * before another tenant's job objects reach the service or controller. + * Proves that administrative operations are bounded at the repository layer + * before another tenant's job objects or mutations cross the service boundary. */ class InMemoryConversionJobRepositoryTenantScopeTest { @@ -44,16 +47,121 @@ void nullOrBlankTenantIdentifiersFailClosed() { assertTrue(repository.findAllByTenantId(null).isEmpty()); assertTrue(repository.findAllByTenantId(" ").isEmpty()); + assertFalse(repository.deleteByTenantAndId(null, UUID.randomUUID())); + assertFalse(repository.deleteByTenantAndId(" ", UUID.randomUUID())); + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant(null, UUID.randomUUID(), "actor") + ); + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant(" ", UUID.randomUUID(), "actor") + ); + } + + @Test + void tenantScopedDeleteConcealsMissingAndCrossTenantJobsThenRemovesOwnedIndex() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob north = job("tenant-north", "north.pdf"); + ConversionJob south = job("tenant-south", "south.pdf"); + repository.save(north); + repository.save(south); + + assertFalse(repository.deleteByTenantAndId("tenant-north", UUID.randomUUID())); + assertFalse(repository.deleteByTenantAndId("tenant-north", south.getJobId())); + assertTrue(repository.findById(south.getJobId()).isPresent()); + assertTrue(repository.deleteByTenantAndId(" tenant-north ", north.getJobId())); + assertTrue(repository.findById(north.getJobId()).isEmpty()); + assertTrue(repository.findByTenantAndContentHash( + "tenant-north", + north.getContentHash() + ).isEmpty()); + } + + @Test + void tenantScopedDeleteHandlesJobsWithoutIndexedContentHashes() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob nullHash = job("tenant-north", "null-hash.pdf", null); + ConversionJob blankHash = job("tenant-north", "blank-hash.pdf", " "); + repository.save(nullHash); + repository.save(blankHash); + + assertTrue(repository.deleteByTenantAndId("tenant-north", nullHash.getJobId())); + assertTrue(repository.deleteByTenantAndId("tenant-north", blankHash.getJobId())); + repository.deleteById(UUID.randomUUID()); + } + + @Test + void tenantScopedRetryAtomicallyConcealsOwnershipAndMapsEligibility() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob active = job("tenant-north", "active.pdf"); + ConversionJob retryable = deadLetteredJob("tenant-north", "retryable.pdf"); + ConversionJob south = deadLetteredJob("tenant-south", "south-secret.pdf"); + repository.save(active); + repository.save(retryable); + repository.save(south); + + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant( + "tenant-north", + UUID.randomUUID(), + "actor-missing" + ) + ); + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant( + "tenant-north", + south.getJobId(), + "actor-cross-tenant" + ) + ); + assertEquals( + TenantRetryOutcome.NOT_ELIGIBLE, + repository.retryDeadLetteredForTenant( + "tenant-north", + active.getJobId(), + "actor-active" + ) + ); + assertEquals( + TenantRetryOutcome.ACCEPTED, + repository.retryDeadLetteredForTenant( + " tenant-north ", + retryable.getJobId(), + "actor-owned" + ) + ); + assertEquals(ConversionJobStatus.SUBMITTED, retryable.getStatus()); + assertTrue(retryable.getStatusMessage().contains("actor-owned")); + assertEquals( + 1, + repository.findLifecycleEventsByJobId(retryable.getJobId()).stream() + .filter(event -> "conversion.retry.accepted".equals(event.eventType())) + .count() + ); + } + + private static ConversionJob deadLetteredJob(String tenantId, String fileName) { + ConversionJob job = job(tenantId, fileName); + assertTrue(job.markProcessing("first attempt")); + job.markDeadLettered("retries exhausted"); + return job; } private static ConversionJob job(String tenantId, String fileName) { + return job(tenantId, fileName, UUID.randomUUID().toString()); + } + + private static ConversionJob job(String tenantId, String fileName, String contentHash) { return new ConversionJob( UUID.randomUUID(), tenantId, "owner", fileName, "application/pdf", - UUID.randomUUID().toString(), + contentHash, 100L, 3 ); From 650e5de39a507536dea29b7a7431562eaf3da920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:48:10 +0900 Subject: [PATCH 24/47] test(security): cover fail-closed repository adapter default --- ...enantScopedAtomicMutationBoundaryTest.java | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java b/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java index 36db5c967..c364dbe06 100644 --- a/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java +++ b/src/test/java/com/clearfolio/viewer/service/TenantScopedAtomicMutationBoundaryTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.when; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -50,7 +51,12 @@ void tenantAwareDeleteUsesScopedRepositoryDeletionBeforeArtifactCleanup() throws .thenReturn(false); when(repository.deleteByTenantAndId(TENANT_CONTEXT.tenantId(), ownedId)) .thenReturn(true); - DefaultDocumentConversionService service = service(repository, stateStore, artifactStore, mock(ConversionWorker.class)); + DefaultDocumentConversionService service = service( + repository, + stateStore, + artifactStore, + mock(ConversionWorker.class) + ); assertFalse(service.deleteJob(missingId, TENANT_CONTEXT)); assertTrue(service.deleteJob(ownedId, TENANT_CONTEXT)); @@ -114,6 +120,50 @@ void tenantAwareRetryMapsOneAtomicStateStoreOutcomeAndEnqueuesOnlyAcceptedJobs() verify(worker).enqueue(acceptedId); } + @Test + void compatibilityRepositoryDefaultDoesNotInvokeGlobalDelete() { + AtomicInteger globalDeleteCalls = new AtomicInteger(); + ConversionJobRepository repository = new ConversionJobRepository() { + @Override + public ConversionJob save(ConversionJob job) { + return job; + } + + @Override + public Optional findById(UUID jobId) { + return Optional.empty(); + } + + @Override + public Optional findByContentHash(String contentHash) { + return Optional.empty(); + } + + @Override + public List findAll() { + return List.of(); + } + + @Override + public FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) { + return new FindOrStoreResult(candidate, true); + } + + @Override + public void deleteById(UUID jobId) { + globalDeleteCalls.incrementAndGet(); + } + }; + + boolean deleted = repository.deleteByTenantAndId( + TENANT_CONTEXT.tenantId(), + UUID.randomUUID() + ); + + assertFalse(deleted); + assertEquals(0, globalDeleteCalls.get()); + } + @Test void compatibilityStateStoreDefaultDoesNotInvokeLegacyRetry() { AtomicInteger legacyRetryCalls = new AtomicInteger(); From 3292630d32aaff24fd2901fb61c6c9eb4abba93f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:49:09 +0900 Subject: [PATCH 25/47] docs(security): specify atomic tenant mutation evidence --- ...2026-08-05-administrative-authorization.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index bef4c92da..2642d66fa 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -25,13 +25,17 @@ Every endpoint applies the same fail-closed sequence: 3. Verify signed claims and their freshness. 4. Require the action-specific permission. 5. Pass the verified `TenantContext` into the object-specific service mutation. -6. Select the target through a tenant-scoped repository lookup before evaluating or applying the mutation. +6. Select and mutate the target through one tenant-scoped persistence operation. 7. Return a non-enumerating not-found response for absent or cross-tenant objects. 8. Emit privacy-safe authorization evidence for the resulting outcome. -List responses filter the complete repository result to the verified tenant before applying the optional dead-letter status filter. Delete and retry do not perform controller-level read-then-write authorization. Their service contracts receive the verified tenant context and enforce ownership at the mutation boundary, so non-HTTP callers cannot reach an unscoped administrative mutation by bypassing the controller. +List responses apply the tenant predicate before job objects cross the repository boundary and then apply the optional dead-letter status filter. Delete and retry do not perform controller-level or service-level read-then-write authorization. Their service contracts pass the authenticated tenant to atomic repository or state-store operations, so non-HTTP callers cannot reach an unscoped administrative mutation by bypassing the controller. -The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware interface defaults fail closed by returning `false` or `NOT_FOUND` without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's durable implementation supplies those scoped overrides. +Deletion first performs the tenant-predicate repository deletion. Artifact cleanup is attempted only after that owned deletion succeeds, so a missing or cross-tenant identifier cannot delete another tenant's artifact. A cleanup failure remains an operational orphan-cleanup concern, not an authorization bypass, and is recorded without restoring access to the deleted job. + +Retry receives one atomic state-store outcome: `ACCEPTED`, concealed `NOT_FOUND`, or `NOT_ELIGIBLE`. The worker is enqueued only after the state store has atomically verified ownership and moved the owned dead-lettered job back to submitted state. + +The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware service, repository, and state-store defaults fail closed without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's in-memory durable implementation supplies those scoped atomic overrides. ## Audit evidence @@ -55,11 +59,12 @@ Automated tests must exercise the real signed-claim verifier and prove: - missing `admin:read` or `admin:write` permissions fail before service access; - list results contain only tenant-owned jobs for all dead-letter filter states; - missing and cross-tenant delete/retry targets produce indistinguishable not-found responses; -- delete and retry cross a tenant-aware service boundary without a separate controller lookup or unscoped administrative mutation call; -- compatibility-only service adapters cannot reach `getJob`, legacy delete, or legacy retry through the tenant-aware interface defaults; -- the durable retry service rejects null, missing, and cross-tenant contexts without state transition or worker enqueue; +- delete and retry cross tenant-aware persistence boundaries without a separate lookup or unscoped mutation call; +- failed tenant-scoped deletion never touches the artifact store, while successful deletion cleans the owned artifact after repository authorization; +- compatibility-only service, repository, and state-store adapters cannot reach global lookup, delete, or retry methods through tenant-aware defaults; +- the durable retry state store rejects invalid, missing, cross-tenant, and ineligible targets without an unauthorized transition or worker enqueue; - accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; -- not-found, not-eligible, repository failure, deletion failure, and retry failure paths return stable non-leaking responses; +- not-found, not-eligible, repository failure, artifact-cleanup failure, and retry failure paths return stable non-leaking responses; - audit output contains no raw tenant, subject, signature, filename, or document data; - JaCoCo reports 100% line and branch coverage for the `com.clearfolio.viewer.*` production package. From 774f6842b90516f13bab0a4bd025571482066906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:50:49 +0900 Subject: [PATCH 26/47] docs(changelog): record atomic tenant mutation boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 199279677..c72c333b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 관리자 목록 조회는 repository 경계에서 tenant predicate를 적용한 뒤 선택적 dead-letter 필터를 처리하도록 변경했습니다. - 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해 controller 우회 호출에서도 소유권 검사가 적용되도록 했습니다. - tenant-aware service 기본 구현은 global lookup 또는 legacy mutation을 호출하지 않고 `false`/`NOT_FOUND`로 실패 종료합니다. 모듈형 대체 adapter는 원자적 tenant-scoped mutation을 명시적으로 구현해야 관리자 작업을 성공시킬 수 있습니다. +- 관리자 delete/retry는 `deleteByTenantAndId`와 `retryDeadLetteredForTenant` 원자적 tenant-scoped persistence contract를 사용합니다. artifact cleanup은 소유권이 확인된 repository 삭제가 성공한 뒤에만 실행되며, worker enqueue는 소유권 확인과 dead-letter 상태 전이가 원자적으로 성공한 뒤에만 실행됩니다. - 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다. - buyer-demo profile의 tenant-claims HMAC secret 환경변수 직접 바인딩을 제거하고 공통 Spring config-tree secret mount에서 읽도록 변경했습니다. From 1440b8bda5bff59139c56ce1754e3df2f327a7af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:54:09 +0900 Subject: [PATCH 27/47] test(security): reject stale tenant index and mutation observations --- ...onversionJobRepositoryTenantScopeTest.java | 127 +++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java index 9bacb4b38..13933c05f 100644 --- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -59,6 +60,73 @@ void nullOrBlankTenantIdentifiersFailClosed() { ); } + @Test + void replacingAJobIdentifierCannotLeakThroughThePreviousTenantHashIndex() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + UUID sharedJobId = UUID.randomUUID(); + ConversionJob observedNorth = job( + sharedJobId, + "tenant-north", + "north-original.pdf", + "north-content-hash" + ); + ConversionJob replacementSouth = job( + sharedJobId, + "tenant-south", + "south-replacement.pdf", + "south-content-hash" + ); + repository.save(observedNorth); + repository.save(replacementSouth); + + assertTrue(repository.findByTenantAndContentHash( + "tenant-north", + observedNorth.getContentHash() + ).isEmpty()); + assertSame( + replacementSouth, + repository.findByTenantAndContentHash( + "tenant-south", + replacementSouth.getContentHash() + ).orElseThrow() + ); + + ConversionJob newNorthCandidate = job( + "tenant-north", + "north-recreated.pdf", + observedNorth.getContentHash() + ); + ConversionJobRepository.FindOrStoreResult result = repository.findOrStoreByContentHash( + newNorthCandidate + ); + + assertTrue(result.created()); + assertSame(newNorthCandidate, result.canonicalJob()); + } + + @Test + void staleTenantObservationCannotDeleteAReplacementOwnedByAnotherTenant() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + UUID sharedJobId = UUID.randomUUID(); + ConversionJob observedNorth = job( + sharedJobId, + "tenant-north", + "north-observed.pdf", + "north-observed-hash" + ); + ConversionJob replacementSouth = job( + sharedJobId, + "tenant-south", + "south-current.pdf", + "south-current-hash" + ); + repository.save(observedNorth); + repository.save(replacementSouth); + + assertFalse(repository.deleteByTenantAndId("tenant-north", sharedJobId)); + assertSame(replacementSouth, repository.findById(sharedJobId).orElseThrow()); + } + @Test void tenantScopedDeleteConcealsMissingAndCrossTenantJobsThenRemovesOwnedIndex() { InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); @@ -91,6 +159,38 @@ void tenantScopedDeleteHandlesJobsWithoutIndexedContentHashes() { repository.deleteById(UUID.randomUUID()); } + @Test + void staleTenantObservationCannotRetryAReplacementOwnedByAnotherTenant() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + UUID sharedJobId = UUID.randomUUID(); + ConversionJob observedNorth = deadLetteredJob( + sharedJobId, + "tenant-north", + "north-observed.pdf", + "north-observed-retry-hash" + ); + ConversionJob replacementSouth = deadLetteredJob( + sharedJobId, + "tenant-south", + "south-current.pdf", + "south-current-retry-hash" + ); + repository.save(observedNorth); + repository.save(replacementSouth); + + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant( + "tenant-north", + sharedJobId, + "actor-from-stale-observation" + ) + ); + assertSame(replacementSouth, repository.findById(sharedJobId).orElseThrow()); + assertEquals(ConversionJobStatus.FAILED, replacementSouth.getStatus()); + assertTrue(replacementSouth.isDeadLettered()); + } + @Test void tenantScopedRetryAtomicallyConcealsOwnershipAndMapsEligibility() { InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); @@ -144,7 +244,21 @@ void tenantScopedRetryAtomicallyConcealsOwnershipAndMapsEligibility() { } private static ConversionJob deadLetteredJob(String tenantId, String fileName) { - ConversionJob job = job(tenantId, fileName); + return deadLetteredJob( + UUID.randomUUID(), + tenantId, + fileName, + UUID.randomUUID().toString() + ); + } + + private static ConversionJob deadLetteredJob( + UUID jobId, + String tenantId, + String fileName, + String contentHash + ) { + ConversionJob job = job(jobId, tenantId, fileName, contentHash); assertTrue(job.markProcessing("first attempt")); job.markDeadLettered("retries exhausted"); return job; @@ -155,8 +269,17 @@ private static ConversionJob job(String tenantId, String fileName) { } private static ConversionJob job(String tenantId, String fileName, String contentHash) { + return job(UUID.randomUUID(), tenantId, fileName, contentHash); + } + + private static ConversionJob job( + UUID jobId, + String tenantId, + String fileName, + String contentHash + ) { return new ConversionJob( - UUID.randomUUID(), + jobId, tenantId, "owner", fileName, From 5a557c509efb2cda2eb4de0dd5b5aeeb2ba30304 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:56:03 +0900 Subject: [PATCH 28/47] fix(security): invalidate stale tenant content indexes --- .../InMemoryConversionJobRepository.java | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 06f25759c..550d5909c 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -37,10 +37,9 @@ public class InMemoryConversionJobRepository implements ConversionJobRepository, */ @Override public ConversionJob save(ConversionJob job) { - jobs.put(job.getJobId(), job); - if (job.getContentHash() != null && !job.getContentHash().isBlank()) { - jobsByTenantAndContentHash.putIfAbsent(contentKey(job.getTenantId(), job.getContentHash()), job.getJobId()); - } + ConversionJob previous = jobs.put(job.getJobId(), job); + removeContentIndex(previous); + indexContentHash(job); return job; } @@ -62,15 +61,14 @@ public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(Conver jobsByTenantAndContentHash.compute( contentKey, (key, existingJobId) -> { - if (existingJobId != null) { - ConversionJob existing = jobs.get(existingJobId); - if (existing != null) { - canonical.set(existing); - return existingJobId; - } + ConversionJob existing = existingJobId == null ? null : jobs.get(existingJobId); + if (matchesContentIndex(existing, key)) { + canonical.set(existing); + return existingJobId; } - jobs.put(candidate.getJobId(), candidate); + ConversionJob replaced = jobs.put(candidate.getJobId(), candidate); + removeContentIndex(replaced); created.set(true); canonical.set(candidate); return candidate.getJobId(); @@ -79,7 +77,7 @@ public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(Conver ConversionJob storedJob = canonical.get(); appendLifecycleEvent(storedJob, created.get() ? EVENT_SUBMITTED : EVENT_DEDUPE_HIT, null); - return new ConversionJobRepository.FindOrStoreResult(canonical.get(), created.get()); + return new ConversionJobRepository.FindOrStoreResult(storedJob, created.get()); } /** @@ -107,12 +105,13 @@ public Optional findByTenantAndContentHash(String tenantId, Strin return Optional.empty(); } - UUID jobId = jobsByTenantAndContentHash.get(contentKey(tenantId, contentHash)); + String expectedContentKey = contentKey(tenantId, contentHash); + UUID jobId = jobsByTenantAndContentHash.get(expectedContentKey); if (jobId == null) { return Optional.empty(); } - return findById(jobId); + return findById(jobId).filter(job -> matchesContentIndex(job, expectedContentKey)); } /** @@ -317,6 +316,22 @@ private String normalizeTenantId(String tenantId) { return tenantId == null || tenantId.isBlank() ? "buyer-demo" : tenantId.strip(); } + private boolean matchesContentIndex(ConversionJob job, String expectedContentKey) { + return job != null + && job.getContentHash() != null + && !job.getContentHash().isBlank() + && contentKey(job.getTenantId(), job.getContentHash()).equals(expectedContentKey); + } + + private void indexContentHash(ConversionJob job) { + if (job.getContentHash() != null && !job.getContentHash().isBlank()) { + jobsByTenantAndContentHash.put( + contentKey(job.getTenantId(), job.getContentHash()), + job.getJobId() + ); + } + } + private void removeContentIndex(ConversionJob removed) { if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) { jobsByTenantAndContentHash.remove( From a1949a91573383f21a89b615e6ecb88e639d341c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:57:50 +0900 Subject: [PATCH 29/47] fix(security): make stale index recovery collision-safe --- .../repository/InMemoryConversionJobRepository.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 550d5909c..f010abcaa 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -67,8 +67,13 @@ public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(Conver return existingJobId; } - ConversionJob replaced = jobs.put(candidate.getJobId(), candidate); - removeContentIndex(replaced); + ConversionJob identifierCollision = jobs.putIfAbsent( + candidate.getJobId(), + candidate + ); + if (identifierCollision != null && identifierCollision != candidate) { + throw new IllegalStateException("Conversion job identifier collision."); + } created.set(true); canonical.set(candidate); return candidate.getJobId(); @@ -325,7 +330,7 @@ private boolean matchesContentIndex(ConversionJob job, String expectedContentKey private void indexContentHash(ConversionJob job) { if (job.getContentHash() != null && !job.getContentHash().isBlank()) { - jobsByTenantAndContentHash.put( + jobsByTenantAndContentHash.putIfAbsent( contentKey(job.getTenantId(), job.getContentHash()), job.getJobId() ); From 59107cfebe54fd562441d5afbc04dfb497da9ee5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:59:32 +0900 Subject: [PATCH 30/47] test(security): cover fail-closed job identifier collisions --- ...onversionJobRepositoryTenantScopeTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java index 13933c05f..75cfc41a7 100644 --- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantScopeTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -104,6 +105,35 @@ void replacingAJobIdentifierCannotLeakThroughThePreviousTenantHashIndex() { assertSame(newNorthCandidate, result.canonicalJob()); } + @Test + void findOrStoreRejectsAJobIdentifierCollisionWithoutChangingOwnership() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + UUID sharedJobId = UUID.randomUUID(); + ConversionJob currentSouth = job( + sharedJobId, + "tenant-south", + "south-current.pdf", + "south-current-hash" + ); + ConversionJob collidingNorth = job( + sharedJobId, + "tenant-north", + "north-collision.pdf", + "north-collision-hash" + ); + repository.save(currentSouth); + + assertThrows( + IllegalStateException.class, + () -> repository.findOrStoreByContentHash(collidingNorth) + ); + assertSame(currentSouth, repository.findById(sharedJobId).orElseThrow()); + assertTrue(repository.findByTenantAndContentHash( + "tenant-north", + collidingNorth.getContentHash() + ).isEmpty()); + } + @Test void staleTenantObservationCannotDeleteAReplacementOwnedByAnotherTenant() { InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); From 0abea27ac4a17e558815f43934271fbfbe39cdae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:00:22 +0900 Subject: [PATCH 31/47] refactor(security): keep job identifier collisions fail closed --- .../viewer/repository/InMemoryConversionJobRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index f010abcaa..e20beff86 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -71,7 +71,7 @@ public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(Conver candidate.getJobId(), candidate ); - if (identifierCollision != null && identifierCollision != candidate) { + if (identifierCollision != null) { throw new IllegalStateException("Conversion job identifier collision."); } created.set(true); From cafb89177026270be13582e49c3051b260a193f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:00:56 +0900 Subject: [PATCH 32/47] docs(security): define tenant-bound secondary index invariant --- docs/security/2026-08-05-administrative-authorization.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 2642d66fa..1883a29d7 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -37,6 +37,8 @@ Retry receives one atomic state-store outcome: `ACCEPTED`, concealed `NOT_FOUND` The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware service, repository, and state-store defaults fail closed without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's in-memory durable implementation supplies those scoped atomic overrides. +The content-hash secondary index is also tenant-bound. Replacing a stored UUID invalidates the replaced job's tenant-and-hash index before the replacement is indexed. Lookup validates that the current UUID record still matches the requested tenant-and-hash key, and find-or-store rejects a colliding UUID rather than changing the current record's ownership. These defenses prevent a stale secondary index or stale preliminary observation from disclosing, deleting, retrying, or deduplicating against another tenant's job. + ## Audit evidence Administrative evidence contains only: @@ -62,6 +64,8 @@ Automated tests must exercise the real signed-claim verifier and prove: - delete and retry cross tenant-aware persistence boundaries without a separate lookup or unscoped mutation call; - failed tenant-scoped deletion never touches the artifact store, while successful deletion cleans the owned artifact after repository authorization; - compatibility-only service, repository, and state-store adapters cannot reach global lookup, delete, or retry methods through tenant-aware defaults; +- replacing a UUID cannot leave an old tenant-and-content-hash lookup that resolves to the replacement, and a colliding find-or-store candidate fails without changing current ownership; +- stale observations cannot delete or retry a same-UUID replacement owned by another tenant; - the durable retry state store rejects invalid, missing, cross-tenant, and ineligible targets without an unauthorized transition or worker enqueue; - accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; - not-found, not-eligible, repository failure, artifact-cleanup failure, and retry failure paths return stable non-leaking responses; From d4eb774aed8cf897cdf7c8d4fac5211c82d71d9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:01:41 +0900 Subject: [PATCH 33/47] docs(changelog): record stale tenant index defenses --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c72c333b6..c28a6e89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해 controller 우회 호출에서도 소유권 검사가 적용되도록 했습니다. - tenant-aware service 기본 구현은 global lookup 또는 legacy mutation을 호출하지 않고 `false`/`NOT_FOUND`로 실패 종료합니다. 모듈형 대체 adapter는 원자적 tenant-scoped mutation을 명시적으로 구현해야 관리자 작업을 성공시킬 수 있습니다. - 관리자 delete/retry는 `deleteByTenantAndId`와 `retryDeadLetteredForTenant` 원자적 tenant-scoped persistence contract를 사용합니다. artifact cleanup은 소유권이 확인된 repository 삭제가 성공한 뒤에만 실행되며, worker enqueue는 소유권 확인과 dead-letter 상태 전이가 원자적으로 성공한 뒤에만 실행됩니다. +- tenant-and-content-hash 보조 인덱스가 현재 UUID 레코드의 tenant/hash와 일치하는지 재검증하고, 동일 UUID 교체 시 이전 인덱스를 제거하며, find-or-store UUID 충돌을 fail-closed 처리해 stale 인덱스나 stale 관찰이 다른 tenant 작업으로 해석되는 경로를 차단했습니다. - 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다. - buyer-demo profile의 tenant-claims HMAC secret 환경변수 직접 바인딩을 제거하고 공통 Spring config-tree secret mount에서 읽도록 변경했습니다. From 201e85e44ed3af9dd2b4c3df48f9d4310dc7dc9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:03:10 +0900 Subject: [PATCH 34/47] refactor(coverage): preserve stale-index validation without unreachable branches --- .../repository/InMemoryConversionJobRepository.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index e20beff86..4c3d89828 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -322,10 +322,11 @@ private String normalizeTenantId(String tenantId) { } private boolean matchesContentIndex(ConversionJob job, String expectedContentKey) { - return job != null - && job.getContentHash() != null - && !job.getContentHash().isBlank() - && contentKey(job.getTenantId(), job.getContentHash()).equals(expectedContentKey); + return expectedContentKey.equals( + Optional.ofNullable(job) + .map(current -> contentKey(current.getTenantId(), current.getContentHash())) + .orElse("") + ); } private void indexContentHash(ConversionJob job) { From b47464f3c1a2c75821dba121e090f27b089e178d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:08:46 +0900 Subject: [PATCH 35/47] test: expose tenant index replacement race --- ...onversionJobRepositoryConcurrencyTest.java | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java new file mode 100644 index 000000000..cd63cee0f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java @@ -0,0 +1,183 @@ +package com.clearfolio.viewer.repository; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies that the primary job map and tenant-content secondary index remain + * consistent when tenant-scoped deletion races with replacement of the same + * job identifier. + */ +class InMemoryConversionJobRepositoryConcurrencyTest { + + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10); + + @Test + void tenantDeleteCannotRemoveTheIndexOfAConcurrentReplacement() { + assertTimeoutPreemptively(TEST_TIMEOUT, () -> { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + UUID sharedJobId = UUID.randomUUID(); + CountDownLatch deleteCleanupReached = new CountDownLatch(1); + CountDownLatch releaseDeleteCleanup = new CountDownLatch(1); + CountDownLatch replacementIndexReached = new CountDownLatch(1); + BlockingContentHashJob original = new BlockingContentHashJob( + sharedJobId, + "tenant-north", + "north-original.pdf", + "shared-content-hash", + deleteCleanupReached, + releaseDeleteCleanup + ); + SignallingContentHashJob replacement = new SignallingContentHashJob( + sharedJobId, + "tenant-north", + "north-replacement.pdf", + "shared-content-hash", + replacementIndexReached + ); + repository.save(original); + original.arm(); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future deleteResult = executor.submit( + () -> repository.deleteByTenantAndId("tenant-north", sharedJobId) + ); + assertTrue( + deleteCleanupReached.await(2, TimeUnit.SECONDS), + "tenant delete did not reach secondary-index cleanup" + ); + + Future saveResult = executor.submit(() -> repository.save(replacement)); + boolean replacementEnteredBeforeDeleteReleased = replacementIndexReached.await( + 1, + TimeUnit.SECONDS + ); + if (replacementEnteredBeforeDeleteReleased) { + assertSame(replacement, saveResult.get(2, TimeUnit.SECONDS)); + } + + releaseDeleteCleanup.countDown(); + assertTrue(deleteResult.get(2, TimeUnit.SECONDS)); + assertSame(replacement, saveResult.get(2, TimeUnit.SECONDS)); + } finally { + releaseDeleteCleanup.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS)); + } + + assertSame( + replacement, + repository.findByTenantAndContentHash( + "tenant-north", + replacement.getContentHash() + ).orElseThrow() + ); + }); + } + + /** + * Conversion job that pauses the first armed content-hash read so a test can + * deterministically position another operation between primary-map removal + * and secondary-index cleanup. + */ + private static final class BlockingContentHashJob extends ConversionJob { + + private final CountDownLatch cleanupReached; + private final CountDownLatch cleanupRelease; + private final AtomicBoolean blocked = new AtomicBoolean(); + private volatile boolean armed; + + private BlockingContentHashJob( + UUID jobId, + String tenantId, + String fileName, + String contentHash, + CountDownLatch cleanupReached, + CountDownLatch cleanupRelease + ) { + super( + jobId, + tenantId, + "owner", + fileName, + "application/pdf", + contentHash, + 100L, + 3 + ); + this.cleanupReached = cleanupReached; + this.cleanupRelease = cleanupRelease; + } + + private void arm() { + armed = true; + } + + @Override + public String getContentHash() { + if (armed && blocked.compareAndSet(false, true)) { + cleanupReached.countDown(); + await(cleanupRelease); + } + return super.getContentHash(); + } + } + + /** + * Conversion job that signals when a concurrent save reaches index work. + */ + private static final class SignallingContentHashJob extends ConversionJob { + + private final CountDownLatch indexReached; + + private SignallingContentHashJob( + UUID jobId, + String tenantId, + String fileName, + String contentHash, + CountDownLatch indexReached + ) { + super( + jobId, + tenantId, + "owner", + fileName, + "application/pdf", + contentHash, + 100L, + 3 + ); + this.indexReached = indexReached; + } + + @Override + public String getContentHash() { + indexReached.countDown(); + return super.getContentHash(); + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("concurrency test was interrupted", exception); + } + } +} From e059824675038fc30e52dc2f356c81b63b6cf3bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:09:50 +0900 Subject: [PATCH 36/47] fix: keep tenant content index atomic during replacement --- .../InMemoryConversionJobRepository.java | 116 +++++++++--------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 4c3d89828..f9b06d8b7 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -31,16 +31,19 @@ public class InMemoryConversionJobRepository implements ConversionJobRepository, private final ConcurrentHashMap jobs = new ConcurrentHashMap<>(); private final ConcurrentHashMap jobsByTenantAndContentHash = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue lifecycleEvents = new ConcurrentLinkedQueue<>(); + private final Object jobIndexLock = new Object(); /** * {@inheritDoc} */ @Override public ConversionJob save(ConversionJob job) { - ConversionJob previous = jobs.put(job.getJobId(), job); - removeContentIndex(previous); - indexContentHash(job); - return job; + synchronized (jobIndexLock) { + ConversionJob previous = jobs.put(job.getJobId(), job); + removeContentIndex(previous); + indexContentHash(job); + return job; + } } /** @@ -48,41 +51,43 @@ public ConversionJob save(ConversionJob job) { */ @Override public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) { - String contentHash = candidate.getContentHash(); - if (contentHash == null || contentHash.isBlank()) { - save(candidate); - appendLifecycleEvent(candidate, EVENT_SUBMITTED, null); - return new ConversionJobRepository.FindOrStoreResult(candidate, true); - } - - String contentKey = contentKey(candidate.getTenantId(), contentHash); - AtomicBoolean created = new AtomicBoolean(false); - AtomicReference canonical = new AtomicReference<>(); - jobsByTenantAndContentHash.compute( - contentKey, - (key, existingJobId) -> { - ConversionJob existing = existingJobId == null ? null : jobs.get(existingJobId); - if (matchesContentIndex(existing, key)) { - canonical.set(existing); - return existingJobId; - } + synchronized (jobIndexLock) { + String contentHash = candidate.getContentHash(); + if (contentHash == null || contentHash.isBlank()) { + save(candidate); + appendLifecycleEvent(candidate, EVENT_SUBMITTED, null); + return new ConversionJobRepository.FindOrStoreResult(candidate, true); + } - ConversionJob identifierCollision = jobs.putIfAbsent( - candidate.getJobId(), - candidate - ); - if (identifierCollision != null) { - throw new IllegalStateException("Conversion job identifier collision."); + String contentKey = contentKey(candidate.getTenantId(), contentHash); + AtomicBoolean created = new AtomicBoolean(false); + AtomicReference canonical = new AtomicReference<>(); + jobsByTenantAndContentHash.compute( + contentKey, + (key, existingJobId) -> { + ConversionJob existing = existingJobId == null ? null : jobs.get(existingJobId); + if (matchesContentIndex(existing, key)) { + canonical.set(existing); + return existingJobId; + } + + ConversionJob identifierCollision = jobs.putIfAbsent( + candidate.getJobId(), + candidate + ); + if (identifierCollision != null) { + throw new IllegalStateException("Conversion job identifier collision."); + } + created.set(true); + canonical.set(candidate); + return candidate.getJobId(); } - created.set(true); - canonical.set(candidate); - return candidate.getJobId(); - } - ); + ); - ConversionJob storedJob = canonical.get(); - appendLifecycleEvent(storedJob, created.get() ? EVENT_SUBMITTED : EVENT_DEDUPE_HIT, null); - return new ConversionJobRepository.FindOrStoreResult(storedJob, created.get()); + ConversionJob storedJob = canonical.get(); + appendLifecycleEvent(storedJob, created.get() ? EVENT_SUBMITTED : EVENT_DEDUPE_HIT, null); + return new ConversionJobRepository.FindOrStoreResult(storedJob, created.get()); + } } /** @@ -110,13 +115,15 @@ public Optional findByTenantAndContentHash(String tenantId, Strin return Optional.empty(); } - String expectedContentKey = contentKey(tenantId, contentHash); - UUID jobId = jobsByTenantAndContentHash.get(expectedContentKey); - if (jobId == null) { - return Optional.empty(); - } + synchronized (jobIndexLock) { + String expectedContentKey = contentKey(tenantId, contentHash); + UUID jobId = jobsByTenantAndContentHash.get(expectedContentKey); + if (jobId == null) { + return Optional.empty(); + } - return findById(jobId).filter(job -> matchesContentIndex(job, expectedContentKey)); + return findById(jobId).filter(job -> matchesContentIndex(job, expectedContentKey)); + } } /** @@ -150,22 +157,17 @@ public boolean deleteByTenantAndId(String tenantId, UUID jobId) { return false; } - String normalizedTenantId = tenantId.strip(); - AtomicReference removed = new AtomicReference<>(); - jobs.computeIfPresent(jobId, (ignored, existing) -> { - if (!existing.belongsToTenant(normalizedTenantId)) { - return existing; + synchronized (jobIndexLock) { + String normalizedTenantId = tenantId.strip(); + ConversionJob existing = jobs.get(jobId); + if (existing == null || !existing.belongsToTenant(normalizedTenantId)) { + return false; } - removed.set(existing); - return null; - }); - ConversionJob deletedJob = removed.get(); - if (deletedJob == null) { - return false; + jobs.remove(jobId); + removeContentIndex(existing); + return true; } - removeContentIndex(deletedJob); - return true; } /** @@ -173,7 +175,9 @@ public boolean deleteByTenantAndId(String tenantId, UUID jobId) { */ @Override public void deleteById(UUID jobId) { - removeContentIndex(jobs.remove(jobId)); + synchronized (jobIndexLock) { + removeContentIndex(jobs.remove(jobId)); + } } /** From e3d90790284ea889c15c5c6cafdf968b20a49a03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:10:49 +0900 Subject: [PATCH 37/47] docs: record atomic tenant index updates --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c28a6e89e..fe50cb80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - tenant-aware service 기본 구현은 global lookup 또는 legacy mutation을 호출하지 않고 `false`/`NOT_FOUND`로 실패 종료합니다. 모듈형 대체 adapter는 원자적 tenant-scoped mutation을 명시적으로 구현해야 관리자 작업을 성공시킬 수 있습니다. - 관리자 delete/retry는 `deleteByTenantAndId`와 `retryDeadLetteredForTenant` 원자적 tenant-scoped persistence contract를 사용합니다. artifact cleanup은 소유권이 확인된 repository 삭제가 성공한 뒤에만 실행되며, worker enqueue는 소유권 확인과 dead-letter 상태 전이가 원자적으로 성공한 뒤에만 실행됩니다. - tenant-and-content-hash 보조 인덱스가 현재 UUID 레코드의 tenant/hash와 일치하는지 재검증하고, 동일 UUID 교체 시 이전 인덱스를 제거하며, find-or-store UUID 충돌을 fail-closed 처리해 stale 인덱스나 stale 관찰이 다른 tenant 작업으로 해석되는 경로를 차단했습니다. +- tenant-scoped 삭제와 동일 UUID 교체가 동시에 발생해도 primary job map과 tenant-content secondary index가 하나의 임계 구역에서 함께 갱신되도록 직렬화해, 교체 작업의 유효 인덱스를 지연된 삭제 정리가 제거하는 경쟁 조건을 차단했습니다. - 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다. - buyer-demo profile의 tenant-claims HMAC secret 환경변수 직접 바인딩을 제거하고 공통 Spring config-tree secret mount에서 읽도록 변경했습니다. From 310b2ee67dff58ef44b4c10c8b7b07ce878e3dc0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:11:25 +0900 Subject: [PATCH 38/47] docs: define atomic tenant index invariant --- docs/security/2026-08-05-administrative-authorization.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 1883a29d7..25e94e6fe 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -39,6 +39,8 @@ The historical unscoped delete method and two-argument retry method remain compa The content-hash secondary index is also tenant-bound. Replacing a stored UUID invalidates the replaced job's tenant-and-hash index before the replacement is indexed. Lookup validates that the current UUID record still matches the requested tenant-and-hash key, and find-or-store rejects a colliding UUID rather than changing the current record's ownership. These defenses prevent a stale secondary index or stale preliminary observation from disclosing, deleting, retrying, or deduplicating against another tenant's job. +The in-memory adapter updates the primary UUID map and the tenant-content secondary index under one shared critical section for save, find-or-store, indexed lookup, tenant-scoped deletion, and compatibility deletion. This prevents a delayed deletion cleanup from removing the valid secondary index of a concurrent same-UUID replacement. Durable adapters must provide the equivalent invariant with a database transaction, conditional write, or another storage-native atomicity mechanism; process-local locking is not an interoperability contract. + ## Audit evidence Administrative evidence contains only: @@ -65,6 +67,7 @@ Automated tests must exercise the real signed-claim verifier and prove: - failed tenant-scoped deletion never touches the artifact store, while successful deletion cleans the owned artifact after repository authorization; - compatibility-only service, repository, and state-store adapters cannot reach global lookup, delete, or retry methods through tenant-aware defaults; - replacing a UUID cannot leave an old tenant-and-content-hash lookup that resolves to the replacement, and a colliding find-or-store candidate fails without changing current ownership; +- a concurrent tenant-scoped delete cannot remove the tenant-content index of a same-UUID replacement saved while deletion is in progress; - stale observations cannot delete or retry a same-UUID replacement owned by another tenant; - the durable retry state store rejects invalid, missing, cross-tenant, and ineligible targets without an unauthorized transition or worker enqueue; - accepted retry provenance is a domain-separated keyed fingerprint, never a raw or unkeyed subject value; From d6103aa9fafb7476216593fa4026500f099336a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 20:25:09 +0900 Subject: [PATCH 39/47] test(concurrency): require deterministic replacement lock contention --- ...onversionJobRepositoryConcurrencyTest.java | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java index cd63cee0f..601f72205 100644 --- a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryConcurrencyTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; import java.time.Duration; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -12,6 +14,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -33,7 +36,10 @@ void tenantDeleteCannotRemoveTheIndexOfAConcurrentReplacement() { UUID sharedJobId = UUID.randomUUID(); CountDownLatch deleteCleanupReached = new CountDownLatch(1); CountDownLatch releaseDeleteCleanup = new CountDownLatch(1); + CountDownLatch replacementTaskStarted = new CountDownLatch(1); CountDownLatch replacementIndexReached = new CountDownLatch(1); + AtomicReference deleteThread = new AtomicReference<>(); + AtomicReference replacementThread = new AtomicReference<>(); BlockingContentHashJob original = new BlockingContentHashJob( sharedJobId, "tenant-north", @@ -54,26 +60,41 @@ void tenantDeleteCannotRemoveTheIndexOfAConcurrentReplacement() { ExecutorService executor = Executors.newFixedThreadPool(2); try { - Future deleteResult = executor.submit( - () -> repository.deleteByTenantAndId("tenant-north", sharedJobId) - ); + Future deleteResult = executor.submit(() -> { + deleteThread.set(Thread.currentThread()); + return repository.deleteByTenantAndId("tenant-north", sharedJobId); + }); assertTrue( deleteCleanupReached.await(2, TimeUnit.SECONDS), "tenant delete did not reach secondary-index cleanup" ); - Future saveResult = executor.submit(() -> repository.save(replacement)); - boolean replacementEnteredBeforeDeleteReleased = replacementIndexReached.await( - 1, - TimeUnit.SECONDS + Future saveResult = executor.submit(() -> { + replacementThread.set(Thread.currentThread()); + replacementTaskStarted.countDown(); + return repository.save(replacement); + }); + assertTrue( + replacementTaskStarted.await(2, TimeUnit.SECONDS), + "replacement save task did not start" + ); + assertBlockedByDeleteCriticalSection( + replacementThread.get(), + deleteThread.get(), + saveResult + ); + assertTrue( + replacementIndexReached.getCount() == 1L, + "replacement reached index work before delete released the critical section" ); - if (replacementEnteredBeforeDeleteReleased) { - assertSame(replacement, saveResult.get(2, TimeUnit.SECONDS)); - } releaseDeleteCleanup.countDown(); assertTrue(deleteResult.get(2, TimeUnit.SECONDS)); assertSame(replacement, saveResult.get(2, TimeUnit.SECONDS)); + assertTrue( + replacementIndexReached.await(2, TimeUnit.SECONDS), + "replacement did not reach index work after delete released the critical section" + ); } finally { releaseDeleteCleanup.countDown(); executor.shutdownNow(); @@ -90,6 +111,46 @@ void tenantDeleteCannotRemoveTheIndexOfAConcurrentReplacement() { }); } + /** + * Requires the replacement save to be blocked on the repository monitor + * still owned by the paused delete operation. A completed save is a failure, + * because it means the test did not establish the intended interleaving. + * + * @param replacementThread thread executing the replacement save + * @param deleteThread thread holding the repository critical section + * @param saveResult replacement-save future used to reject early completion + */ + private static void assertBlockedByDeleteCriticalSection( + Thread replacementThread, + Thread deleteThread, + Future saveResult + ) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + assertTrue( + !saveResult.isDone(), + "replacement save completed before delete cleanup was released" + ); + if (replacementThread.getState() == Thread.State.BLOCKED) { + ThreadInfo threadInfo = ManagementFactory.getThreadMXBean().getThreadInfo( + replacementThread.threadId() + ); + assertTrue(threadInfo != null, "replacement thread metadata was unavailable"); + assertTrue( + threadInfo.getLockOwnerId() == deleteThread.threadId(), + "replacement save blocked on a monitor not owned by the delete operation" + ); + return; + } + Thread.onSpinWait(); + } + assertTrue( + false, + "replacement save did not block on the delete critical section; observed state=" + + replacementThread.getState() + ); + } + /** * Conversion job that pauses the first armed content-hash read so a test can * deterministically position another operation between primary-map removal From 6f522f123465a3fff5de6d47963125097f8da03b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:35:43 +0900 Subject: [PATCH 40/47] test(security): require missing scoped job IDs to fail closed --- ...ionJobRepositoryMissingIdentifierTest.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryMissingIdentifierTest.java diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryMissingIdentifierTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryMissingIdentifierTest.java new file mode 100644 index 000000000..890a1ab80 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryMissingIdentifierTest.java @@ -0,0 +1,49 @@ +package com.clearfolio.viewer.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.repository.ConversionJobStateStore.TenantRetryOutcome; + +/** + * Proves that tenant-scoped repository operations conceal an absent job + * identifier instead of throwing before the service can return its stable + * not-found contract. + */ +class InMemoryConversionJobRepositoryMissingIdentifierTest { + + /** + * Exercises each tenant-scoped lookup or mutation with a missing job + * identifier while a real tenant-owned job exists. The existing job proves + * the result is caused by the missing identifier rather than an empty store. + */ + @Test + void missingJobIdentifierFailsClosedAcrossScopedOperations() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob ownedJob = new ConversionJob( + UUID.randomUUID(), + "tenant-north", + "subject-north", + "contract.pdf", + "application/pdf", + "content-hash", + 128L, + 3 + ); + repository.save(ownedJob); + + assertTrue(repository.findByTenantAndId("tenant-north", null).isEmpty()); + assertFalse(repository.deleteByTenantAndId("tenant-north", null)); + assertEquals( + TenantRetryOutcome.NOT_FOUND, + repository.retryDeadLetteredForTenant("tenant-north", null, "actor:v1") + ); + assertTrue(repository.findById(ownedJob.getJobId()).isPresent()); + } +} From 4161ad00a610268add6dce5d67720c3c30f3d11b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:36:07 +0900 Subject: [PATCH 41/47] ci(test): prove missing scoped job ID regression --- .../workflows/one-shot-tenant-null-id-red.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/one-shot-tenant-null-id-red.yml diff --git a/.github/workflows/one-shot-tenant-null-id-red.yml b/.github/workflows/one-shot-tenant-null-id-red.yml new file mode 100644 index 000000000..39629f097 --- /dev/null +++ b/.github/workflows/one-shot-tenant-null-id-red.yml @@ -0,0 +1,41 @@ +name: One-shot tenant missing identifier RED + +on: + pull_request: + branches: + - fix/pii-logging-16240128950440010639 + paths: + - .github/workflows/one-shot-tenant-null-id-red.yml + +permissions: + contents: read + +jobs: + prove-red: + if: >- + github.repository == 'ContextualWisdomLab/clearfolio' + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.head.ref == 'fix/admin-endpoint-auth-clean' + runs-on: ubuntu-latest + steps: + - name: Checkout exact pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + + - name: Use preinstalled Temurin JDK 21 + run: | + echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" + echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" + + - name: Prove missing scoped identifier contract is RED + run: >- + mvn -B --no-transfer-progress + -Dtest=InMemoryConversionJobRepositoryMissingIdentifierTest + test From 03f1ee6c5d995058cc8d2a3832d2112860ffb758 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:37:04 +0900 Subject: [PATCH 42/47] ci(test): run scoped missing-ID RED proof after reopen --- .github/workflows/one-shot-tenant-null-id-red.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/one-shot-tenant-null-id-red.yml b/.github/workflows/one-shot-tenant-null-id-red.yml index 39629f097..820d84b2f 100644 --- a/.github/workflows/one-shot-tenant-null-id-red.yml +++ b/.github/workflows/one-shot-tenant-null-id-red.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: tenant-null-id-red-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + cancel-in-progress: false + jobs: prove-red: if: >- From 3db237778536ba9f9ece7eb076559d45dfbfe118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:40:13 +0900 Subject: [PATCH 43/47] fix(security): conceal missing scoped lookup identifiers --- .../viewer/repository/ConversionJobRepository.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java index 0620d232f..f567dccf5 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java @@ -66,7 +66,10 @@ default Optional findByTenantAndContentHash(String tenantId, Stri * @return matching conversion job when found and owned by the tenant */ default Optional findByTenantAndId(String tenantId, UUID jobId) { - return findById(jobId).filter(job -> job.belongsToTenant(tenantId)); + if (tenantId == null || tenantId.isBlank() || jobId == null) { + return Optional.empty(); + } + return findById(jobId).filter(job -> job.belongsToTenant(tenantId.strip())); } /** From 60518a88005b629128fed3bbcc77daf1772b9abf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:41:29 +0900 Subject: [PATCH 44/47] fix(security): fail closed on missing scoped mutation IDs --- .../viewer/repository/InMemoryConversionJobRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index f9b06d8b7..503d34601 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -153,7 +153,7 @@ public List findAllByTenantId(String tenantId) { */ @Override public boolean deleteByTenantAndId(String tenantId, UUID jobId) { - if (tenantId == null || tenantId.isBlank()) { + if (tenantId == null || tenantId.isBlank() || jobId == null) { return false; } @@ -291,7 +291,7 @@ public TenantRetryOutcome retryDeadLetteredForTenant( UUID jobId, String operatorId ) { - if (tenantId == null || tenantId.isBlank()) { + if (tenantId == null || tenantId.isBlank() || jobId == null) { return TenantRetryOutcome.NOT_FOUND; } From 17d9d66332ed351c2f003e8d57f4d2711f29de45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:42:13 +0900 Subject: [PATCH 45/47] chore(ci): remove one-shot missing-ID RED proof --- .../workflows/one-shot-tenant-null-id-red.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/one-shot-tenant-null-id-red.yml diff --git a/.github/workflows/one-shot-tenant-null-id-red.yml b/.github/workflows/one-shot-tenant-null-id-red.yml deleted file mode 100644 index 820d84b2f..000000000 --- a/.github/workflows/one-shot-tenant-null-id-red.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: One-shot tenant missing identifier RED - -on: - pull_request: - branches: - - fix/pii-logging-16240128950440010639 - paths: - - .github/workflows/one-shot-tenant-null-id-red.yml - -permissions: - contents: read - -concurrency: - group: tenant-null-id-red-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} - cancel-in-progress: false - -jobs: - prove-red: - if: >- - github.repository == 'ContextualWisdomLab/clearfolio' - && github.event.pull_request.head.repo.full_name == github.repository - && github.event.pull_request.head.ref == 'fix/admin-endpoint-auth-clean' - runs-on: ubuntu-latest - steps: - - name: Checkout exact pull request head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} - - - name: Verify exact checked-out revision - env: - EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - - name: Use preinstalled Temurin JDK 21 - run: | - echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" - echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" - - - name: Prove missing scoped identifier contract is RED - run: >- - mvn -B --no-transfer-progress - -Dtest=InMemoryConversionJobRepositoryMissingIdentifierTest - test From 3e519b2e7a937b833bcd25539eda65d47761bde6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:43:24 +0900 Subject: [PATCH 46/47] docs(security): define missing scoped identifier behavior --- docs/security/2026-08-05-administrative-authorization.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/security/2026-08-05-administrative-authorization.md b/docs/security/2026-08-05-administrative-authorization.md index 25e94e6fe..15712fe25 100644 --- a/docs/security/2026-08-05-administrative-authorization.md +++ b/docs/security/2026-08-05-administrative-authorization.md @@ -35,6 +35,8 @@ Deletion first performs the tenant-predicate repository deletion. Artifact clean Retry receives one atomic state-store outcome: `ACCEPTED`, concealed `NOT_FOUND`, or `NOT_ELIGIBLE`. The worker is enqueued only after the state store has atomically verified ownership and moved the owned dead-lettered job back to submitted state. +A missing job identifier is treated as an absent object at every tenant-scoped lookup, delete, and retry boundary. Repository adapters return an empty lookup, `false`, or `NOT_FOUND` without invoking a global lookup, mutating stored state, deleting an artifact, or enqueueing work. This keeps internal and modular callers on the same non-enumerating contract even when a malformed or incomplete adapter call bypasses HTTP path binding. + The historical unscoped delete method and two-argument retry method remain compatibility contracts for non-administrative adapters only. Their tenant-aware service, repository, and state-store defaults fail closed without reading a job or invoking either legacy mutation. A production adapter must explicitly override the tenant-aware methods and perform tenant selection and mutation within one persistence boundary before an administrative request can succeed. Clearfolio's in-memory durable implementation supplies those scoped atomic overrides. The content-hash secondary index is also tenant-bound. Replacing a stored UUID invalidates the replaced job's tenant-and-hash index before the replacement is indexed. Lookup validates that the current UUID record still matches the requested tenant-and-hash key, and find-or-store rejects a colliding UUID rather than changing the current record's ownership. These defenses prevent a stale secondary index or stale preliminary observation from disclosing, deleting, retrying, or deduplicating against another tenant's job. @@ -63,6 +65,7 @@ Automated tests must exercise the real signed-claim verifier and prove: - missing `admin:read` or `admin:write` permissions fail before service access; - list results contain only tenant-owned jobs for all dead-letter filter states; - missing and cross-tenant delete/retry targets produce indistinguishable not-found responses; +- a missing job identifier fails closed across tenant-scoped lookup, delete, and retry while leaving existing stored state unchanged; - delete and retry cross tenant-aware persistence boundaries without a separate lookup or unscoped mutation call; - failed tenant-scoped deletion never touches the artifact store, while successful deletion cleans the owned artifact after repository authorization; - compatibility-only service, repository, and state-store adapters cannot reach global lookup, delete, or retry methods through tenant-aware defaults; From 7737503f44d70f5180f7098554030faba6c73b0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:44:31 +0900 Subject: [PATCH 47/47] docs(changelog): record missing scoped ID concealment --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe50cb80f..31bcd06dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - 관리자 delete/retry가 검증된 `TenantContext`를 tenant-aware service mutation boundary에 전달하도록 변경해 controller 우회 호출에서도 소유권 검사가 적용되도록 했습니다. - tenant-aware service 기본 구현은 global lookup 또는 legacy mutation을 호출하지 않고 `false`/`NOT_FOUND`로 실패 종료합니다. 모듈형 대체 adapter는 원자적 tenant-scoped mutation을 명시적으로 구현해야 관리자 작업을 성공시킬 수 있습니다. - 관리자 delete/retry는 `deleteByTenantAndId`와 `retryDeadLetteredForTenant` 원자적 tenant-scoped persistence contract를 사용합니다. artifact cleanup은 소유권이 확인된 repository 삭제가 성공한 뒤에만 실행되며, worker enqueue는 소유권 확인과 dead-letter 상태 전이가 원자적으로 성공한 뒤에만 실행됩니다. +- tenant-scoped lookup/delete/retry는 누락된 job UUID를 예외로 노출하지 않고 empty/`false`/`NOT_FOUND`로 실패 종료하며 기존 저장 상태를 변경하지 않습니다. - tenant-and-content-hash 보조 인덱스가 현재 UUID 레코드의 tenant/hash와 일치하는지 재검증하고, 동일 UUID 교체 시 이전 인덱스를 제거하며, find-or-store UUID 충돌을 fail-closed 처리해 stale 인덱스나 stale 관찰이 다른 tenant 작업으로 해석되는 경로를 차단했습니다. - tenant-scoped 삭제와 동일 UUID 교체가 동시에 발생해도 primary job map과 tenant-content secondary index가 하나의 임계 구역에서 함께 갱신되도록 직렬화해, 교체 작업의 유효 인덱스를 지연된 삭제 정리가 제거하는 경쟁 조건을 차단했습니다. - 관리자 허용·거부·미존재·재시도 불가·실패 결정을 actor/tenant별 도메인 분리 HMAC 지문으로 기록하고, raw subject·tenant·claim signature·문서 메타데이터가 감사 로그와 retry provenance에 남지 않도록 했습니다.