From 85ad5285075432ce1cdee9fd37299cbbeb0d44b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 14:23:52 +0900 Subject: [PATCH 1/4] test(security): require tenant claims to consume credential registry --- ...ntAccessServiceCredentialRegistryTest.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java new file mode 100644 index 000000000..93182a0b3 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java @@ -0,0 +1,78 @@ +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.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +import com.clearfolio.viewer.credential.CredentialPurpose; +import com.clearfolio.viewer.credential.CredentialReference; +import com.clearfolio.viewer.credential.CredentialRegistry; +import com.clearfolio.viewer.credential.CredentialSnapshot; + +class TenantAccessServiceCredentialRegistryTest { + + private static final String SECRET = "tenant-registry-signing-key"; + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + + @Test + void registryBackedServiceValidatesClaimsWithPurposeScopedSecretBytes() { + CredentialRegistry registry = reference -> { + assertEquals("tenant-claims-signing", reference.credentialName()); + assertEquals(CredentialPurpose.TENANT_CLAIMS_SIGNING, reference.purpose()); + return new CredentialSnapshot( + reference.credentialName(), + "v1", + CredentialPurpose.TENANT_CLAIMS_SIGNING, + SECRET.getBytes(StandardCharsets.UTF_8) + ); + }; + TenantAccessService service = new TenantAccessService( + registry, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + HttpHeaders headers = signedHeaders(); + + assertDoesNotThrow(() -> service.require(headers, TenantPermissions.JOB_READ)); + } + + @Test + void registryBackedServiceRejectsWrongPurposeSnapshot() { + CredentialRegistry registry = reference -> new CredentialSnapshot( + reference.credentialName(), + "v1", + CredentialPurpose.ARTIFACT_TOKEN_SIGNING, + SECRET.getBytes(StandardCharsets.UTF_8) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new TenantAccessService(registry, 300L, Clock.fixed(NOW, ZoneOffset.UTC)) + ); + + assertEquals("tenant claims credential purpose mismatch", exception.getMessage()); + } + + private static HttpHeaders signedHeaders() { + 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, TenantPermissions.JOB_READ); + String issuedAt = String.valueOf(NOW.getEpochSecond()); + TenantContext context = TenantContext.fromHeaders(headers).orElseThrow(); + headers.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, issuedAt); + headers.set( + TenantContext.CLAIMS_SIGNATURE_HEADER, + TenantAccessService.signClaims(context, issuedAt, SECRET) + ); + return headers; + } +} From 0d0d850c54defce323931e8a3ba61453eac2280a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:06:29 +0900 Subject: [PATCH 2/4] feat(security): add registry-backed tenant claim verification --- .../viewer/auth/TenantAccessService.java | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java b/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java index ab02e96a7..fc003d84e 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java @@ -6,6 +6,7 @@ import java.time.Clock; import java.time.Instant; import java.util.Base64; +import java.util.Objects; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -17,6 +18,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import com.clearfolio.viewer.credential.CredentialPurpose; +import com.clearfolio.viewer.credential.CredentialReference; +import com.clearfolio.viewer.credential.CredentialRegistry; +import com.clearfolio.viewer.credential.CredentialSnapshot; import com.clearfolio.viewer.model.ConversionJob; /** @@ -27,8 +32,12 @@ public class TenantAccessService { private static final String HMAC_SHA_256 = "HmacSHA256"; private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final CredentialReference TENANT_CLAIMS_CREDENTIAL = new CredentialReference( + "tenant-claims-signing", + CredentialPurpose.TENANT_CLAIMS_SIGNING + ); - private final String claimsHmacSecret; + private final byte[] claimsHmacSecret; private final long maxSkewSeconds; private final Clock clock; @@ -42,6 +51,11 @@ public TenantAccessService() { /** * Creates an access service with optional signed gateway claim validation. * + *

This compatibility constructor remains the current Spring wiring until + * the credential-registry bootstrap stack is integrated. Production + * application wiring must migrate to the registry-backed constructor rather + * than treating this property as long-lived secret authority.

+ * * @param claimsHmacSecret optional shared gateway HMAC secret * @param maxSkewSeconds maximum accepted clock skew in seconds */ @@ -52,10 +66,46 @@ public TenantAccessService( this(claimsHmacSecret, maxSkewSeconds, Clock.systemUTC()); } + /** + * Creates a verifier from the provider-neutral credential registry. + * + *

The server-owned reference is fixed to tenant-claim signing. The + * returned snapshot must bind both the expected logical credential and + * purpose before its opaque bytes become verification authority.

+ * + * @param credentialRegistry provider-neutral credential authority + * @param maxSkewSeconds maximum accepted clock skew in seconds + * @param clock verification clock + * @throws NullPointerException when registry, clock, or snapshot is absent + * @throws IllegalStateException when registry metadata does not match the + * server-owned tenant-claims reference + */ + public TenantAccessService( + CredentialRegistry credentialRegistry, + long maxSkewSeconds, + Clock clock + ) { + CredentialSnapshot snapshot = Objects.requireNonNull( + Objects.requireNonNull(credentialRegistry, "credentialRegistry") + .resolve(TENANT_CLAIMS_CREDENTIAL), + "tenant claims credential snapshot" + ); + if (!TENANT_CLAIMS_CREDENTIAL.credentialName().equals(snapshot.credentialId()) + || snapshot.purpose() != TENANT_CLAIMS_CREDENTIAL.purpose()) { + throw new IllegalStateException("tenant claims credential purpose mismatch"); + } + this.claimsHmacSecret = snapshot.secretBytes(); + this.maxSkewSeconds = Math.max(0L, maxSkewSeconds); + this.clock = Objects.requireNonNull(clock, "clock"); + } + TenantAccessService(String claimsHmacSecret, long maxSkewSeconds, Clock clock) { - this.claimsHmacSecret = clean(claimsHmacSecret); + String cleanedSecret = clean(claimsHmacSecret); + this.claimsHmacSecret = cleanedSecret == null + ? null + : cleanedSecret.getBytes(StandardCharsets.UTF_8); this.maxSkewSeconds = Math.max(0L, maxSkewSeconds); - this.clock = clock; + this.clock = Objects.requireNonNull(clock, "clock"); } /** @@ -135,6 +185,14 @@ private static long parseIssuedAt(String issuedAt) { * @return Base64URL HMAC signature */ public static String signClaims(TenantContext context, String issuedAt, String secret) { + return signClaims( + context, + issuedAt, + Objects.requireNonNull(secret, "secret").getBytes(StandardCharsets.UTF_8) + ); + } + + private static String signClaims(TenantContext context, String issuedAt, byte[] secret) { String payload = String.join("\n", context.tenantId(), context.subjectId(), @@ -144,10 +202,10 @@ public static String signClaims(TenantContext context, String issuedAt, String s return hmac(payload, secret); } - private static String hmac(String payload, String secret) { + private static String hmac(String payload, byte[] secret) { try { Mac mac = Mac.getInstance(HMAC_SHA_256); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA_256)); + mac.init(new SecretKeySpec(secret, HMAC_SHA_256)); return URL_ENCODER.encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); } catch (GeneralSecurityException ex) { throw new IllegalStateException("tenant claims signing failed", ex); From 4071808854b98731542f44f724242f95f2fcb4c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:07:23 +0900 Subject: [PATCH 3/4] test(security): verify tenant credential identity and purpose --- ...ntAccessServiceCredentialRegistryTest.java | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java index 93182a0b3..0bfcb077d 100644 --- a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java +++ b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceCredentialRegistryTest.java @@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders; import com.clearfolio.viewer.credential.CredentialPurpose; -import com.clearfolio.viewer.credential.CredentialReference; import com.clearfolio.viewer.credential.CredentialRegistry; import com.clearfolio.viewer.credential.CredentialSnapshot; @@ -61,6 +60,56 @@ void registryBackedServiceRejectsWrongPurposeSnapshot() { assertEquals("tenant claims credential purpose mismatch", exception.getMessage()); } + @Test + void registryBackedServiceRejectsWrongCredentialIdentity() { + CredentialRegistry registry = reference -> new CredentialSnapshot( + "different-credential", + "v1", + CredentialPurpose.TENANT_CLAIMS_SIGNING, + SECRET.getBytes(StandardCharsets.UTF_8) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new TenantAccessService(registry, 300L, Clock.fixed(NOW, ZoneOffset.UTC)) + ); + + assertEquals("tenant claims credential purpose mismatch", exception.getMessage()); + } + + @Test + void registryBackedServiceFailsClosedForMissingAuthority() { + assertThrows( + NullPointerException.class, + () -> new TenantAccessService( + (CredentialRegistry) null, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ) + ); + assertThrows( + NullPointerException.class, + () -> new TenantAccessService( + reference -> null, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ) + ); + assertThrows( + NullPointerException.class, + () -> new TenantAccessService( + reference -> new CredentialSnapshot( + reference.credentialName(), + "v1", + CredentialPurpose.TENANT_CLAIMS_SIGNING, + SECRET.getBytes(StandardCharsets.UTF_8) + ), + 300L, + null + ) + ); + } + private static HttpHeaders signedHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); From 7a6a1722d15c7e985ed1fb29dc025ee97b6a01a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:11:50 +0900 Subject: [PATCH 4/4] test(security): disambiguate null secret constructor --- .../com/clearfolio/viewer/auth/TenantAccessServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceTest.java index 3ab76cadc..a1b0cdc5f 100644 --- a/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/auth/TenantAccessServiceTest.java @@ -46,7 +46,11 @@ void requireAcceptsSignedGatewayClaimsWhenSecretIsConfigured() { @Test void requireSkipsSignatureValidationWhenSecretIsBlankOrNull() { TenantAccessService blankSecret = new TenantAccessService(" ", 300L, Clock.fixed(NOW, ZoneOffset.UTC)); - TenantAccessService nullSecret = new TenantAccessService(null, 300L, Clock.fixed(NOW, ZoneOffset.UTC)); + TenantAccessService nullSecret = new TenantAccessService( + (String) null, + 300L, + Clock.fixed(NOW, ZoneOffset.UTC) + ); assertDoesNotThrow(() -> blankSecret.require(headers(TenantPermissions.JOB_READ), TenantPermissions.JOB_READ)); assertDoesNotThrow(() -> nullSecret.require(headers(TenantPermissions.JOB_READ), TenantPermissions.JOB_READ));