chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# adapter:outbound:identifier — non-IO infrastructure-capability adapter
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-identifier`
|
||||
- Gradle path: `:adapter:outbound:identifier`
|
||||
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:identifier:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `src/config/architecture/modules.json`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.identifier`.
|
||||
|
||||
Design decisions previously kept as code comments (algorithm SSOT, salt origin,
|
||||
build choices) live in [README.md](README.md).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Driven adapters for capabilities with **no external-system integration**:
|
||||
identifier generation/encoding today, and clock / crypto/random sources by the
|
||||
same rationale (feature-resource-identifier-contract §4 taxonomy).
|
||||
- `UuidCodec` — UUID handling on top of the JDK `java.util.UUID` (RFC 9562 UUIDv7):
|
||||
`normalize(String)` accepts a case-insensitive canonical UUID and returns the
|
||||
canonical 36-character lowercase form (D3); `toUuid` / `fromUuid` convert between
|
||||
the UUID string and the 128-bit `UUID` stored in the PostgreSQL `uuid` column (D10).
|
||||
- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is
|
||||
infrastructure, not an outbound integration point, so `adapter-outbound` keeps its
|
||||
documented meaning (external HTTP / messaging / cache / notifications).
|
||||
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently
|
||||
only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in
|
||||
[build.gradle](build.gradle).
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
|
||||
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
|
||||
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
|
||||
- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`,
|
||||
`sample-portfolio`.
|
||||
- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`.
|
||||
|
||||
## Test
|
||||
|
||||
Pure unit tests, no Spring context (`UuidCodecSpec`).
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:identifier:test --console=plain
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# adapter-identifier — 설계 결정 참조
|
||||
|
||||
비-IO 인프라 능력(capability) 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.identifier`.
|
||||
|
||||
허용/금지 의존과 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다.
|
||||
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
|
||||
"왜 이렇게 했나"가 궁금할 때 본다.
|
||||
|
||||
## 왜 별도 모듈인가 (adapter-outbound 와의 구분)
|
||||
|
||||
`adapter-outbound` 처럼 도메인 포트를 구현하는 driven/secondary 어댑터지만, **외부 시스템 연동이
|
||||
없는**(no external-system integration) 능력만 담는다: 식별자 생성/인코딩(UUIDv7), 같은 근거로 clock·
|
||||
crypto/random 소스. UUID id/코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로, 이것을
|
||||
`adapter-outbound` 밖에 둬야 그 모듈의 문서화된 의미("외부 HTTP / messaging / cache / notifications")가
|
||||
유지된다.
|
||||
|
||||
## UuidCodec
|
||||
|
||||
도메인 무관 UUID 변환 유틸. JDK `java.util.UUID`(RFC 9562 UUIDv7) 위에서 동작한다.
|
||||
|
||||
- `normalize(String)` — **D3**: 대소문자 무관 canonical UUID 입력을 받아 canonical 36자 소문자
|
||||
형태로 반환. 형식 오류 UUID 에는 `IllegalArgumentException`. `null` 입력은 `null` 반환.
|
||||
- `toUuid(String)` — **D10**: UUID 문자열 → 128-bit `UUID` (PostgreSQL `uuid` 컬럼용).
|
||||
- `fromUuid(UUID)` — **D10**: 저장된 `UUID` → canonical 36자 소문자 UUID 문자열.
|
||||
|
||||
## HmacUserPrincipalPseudonymizer
|
||||
|
||||
`UserPrincipalPseudonymizerPort`(application-core) 의 HMAC-SHA-256 구현.
|
||||
|
||||
### 알고리즘 SSOT
|
||||
구체 알고리즘은 90일 회전 salt 로 키잉한 HMAC-SHA-256 이다. 이 클래스가 유일한 구현이며,
|
||||
비-IO crypto 능력 어댑터로 이 모듈에 있고 `app-bootstrap`
|
||||
이 싱글톤 빈으로 와이어링한다.
|
||||
|
||||
### 출력
|
||||
비어있지 않은 `rawPrincipal` 에 대해 단방향·안정적인 256-bit HMAC 토큰을 64자 소문자 hex 로 반환.
|
||||
`rawPrincipal` 이 `null` 이거나 blank 면 `null` 반환.
|
||||
|
||||
### Salt 출처
|
||||
salt 는 `app-bootstrap` 이 `APP_PRIVACY_PSEUDONYMIZATION_SALT` 환경변수에서 공급한다(분류: secret,
|
||||
회전 주기: 90일). 이 클래스는 salt 를 스스로 조달하지 않는다.
|
||||
|
||||
### Thread safety
|
||||
`Mac` 인스턴스는 thread-safe 하지 않다. 매 `pseudonymize(String)` 호출마다 새 `Mac` 을 생성하므로
|
||||
공유 싱글톤 빈으로 안전하다. HmacSHA256 은 JDK 필수 알고리즘(JCA spec)이라 `NoSuchAlgorithmException`·
|
||||
`InvalidKeyException` 은 사실상 도달 불가능하며, 호출부에 checked exception 잡음을 남기지 않으려고
|
||||
`IllegalStateException` 으로 감싼다.
|
||||
|
||||
### Spring-free
|
||||
이 모듈(`adapter-identifier`)은 설계상 Spring-free 다. 어노테이션이 없고, 빈 생성은
|
||||
`app-bootstrap` 의 책임이다.
|
||||
|
||||
## 빌드 결정 (build.gradle)
|
||||
|
||||
### Groovy / Spock (C2 테스트 형태)
|
||||
순수 값-코덱 동작(`UuidCodec`)은 Groovy/Spock 스펙(`src/test/groovy`)으로 명세한다. core `groovy`
|
||||
플러그인이 컴파일하고, 모든 서브프로젝트에 이미 켜진 JUnit Platform(`useJUnitPlatform()`)에서 실행된다.
|
||||
가드/계약 테스트(`HmacUserPrincipalPseudonymizerTest` — 생성자 가드, 정확한 예외/포맷 계약)는 설계상
|
||||
Java(`src/test/java`)로 둔다. Spock 2.4 / Groovy 4.0 variant 를 쓰며, spock-core 가 groovy.jar 를
|
||||
transitive 로 끌어오므로 data-driven `where:` 스펙에 다른 Groovy 모듈이 필요 없다.
|
||||
|
||||
### implementation vs api
|
||||
`:application-core` 를 `implementation` 으로 선언한다(`api` 아님). adapter-identifier 가 자신의 public
|
||||
ABI 에 application-core 타입을 노출하지 않기 때문이다. 유일한 와이어링 소비자인 `app-bootstrap` 은
|
||||
이미 자기 classpath 에 application-core 를 갖고 있다. 이 의존 edge 는 `src/build.gradle` 의
|
||||
`allowedProjectDependencies['adapter-identifier']` 로 허용된다.
|
||||
|
||||
### UTF-8 인코딩 고정
|
||||
한국어(비-ASCII) Spock 스펙 메서드명은 소스를 UTF-8 로 읽어야만 컴파일·리포팅이 정상이다. 이 모듈이
|
||||
비-ASCII 소스를 처음 갖는 모듈이라 컴파일 인코딩을 명시적으로 고정한다 — UTF-8 호스트에선 no-op 지만,
|
||||
플랫폼 기본이 다른 fork(예: 한국어 Windows / MS949)에서 mojibake 빌드를 막는다. C2 가 더 많은 모듈로
|
||||
퍼지면 root subprojects 블록(`-parameters` 옆)으로 승격한다.
|
||||
@@ -0,0 +1,20 @@
|
||||
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
|
||||
plugins {
|
||||
id 'groovy'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
}
|
||||
|
||||
// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README.
|
||||
tasks.withType(GroovyCompile).configureEach {
|
||||
groovyOptions.encoding = 'UTF-8'
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* HMAC-SHA-256 implementation of {@link UserPrincipalPseudonymizerPort}: returns a stable
|
||||
* 64-character lowercase hex token, or {@code null} for null/blank input. Thread-safe as a shared
|
||||
* singleton. See README for the algorithm SSOT, salt origin, and design rationale.
|
||||
*/
|
||||
public final class HmacUserPrincipalPseudonymizer implements UserPrincipalPseudonymizerPort {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
|
||||
public HmacUserPrincipalPseudonymizer(byte[] salt) {
|
||||
if (salt == null || salt.length == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"HMAC salt must not be null or empty — supplied by APP_PRIVACY_PSEUDONYMIZATION_SALT");
|
||||
}
|
||||
byte[] saltCopy = salt.clone(); // defensive copy; caller's array is not retained
|
||||
this.key = new SecretKeySpec(saltCopy, ALGORITHM);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String pseudonymize(String rawPrincipal) {
|
||||
if (rawPrincipal == null || rawPrincipal.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM); // Mac is not thread-safe — fresh instance per call
|
||||
mac.init(key);
|
||||
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
// Unreachable: HmacSHA256 is a mandatory JDK algorithm and the key spec is valid.
|
||||
throw new IllegalStateException(
|
||||
"HmacSHA256 unavailable or key invalid — this should never happen on a compliant JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import dev.caskeleton.application.fileserver.api.FileId;
|
||||
import dev.caskeleton.application.fileserver.api.UploadId;
|
||||
import dev.caskeleton.application.fileserver.upload.UploadIdentifierFactory;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Unpredictable file and upload identifiers.
|
||||
*
|
||||
* <p>These identifiers are the public handle for a file, so they are drawn from a cryptographically
|
||||
* strong source rather than a sequence or a timestamp. A time-ordered identifier would be the
|
||||
* better database key, and is deliberately not used: it would let anyone holding one id infer when
|
||||
* neighbouring files were created and enumerate towards them, which is exactly what an opaque
|
||||
* handle is for.
|
||||
*
|
||||
* <p>{@link UUID#randomUUID()} is backed by a seeded {@code SecureRandom} and is safe to share
|
||||
* across threads.
|
||||
*/
|
||||
public final class RandomUploadIdentifierFactory implements UploadIdentifierFactory {
|
||||
|
||||
@Override
|
||||
public FileId newFileId() {
|
||||
return FileId.of(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Override
|
||||
public UploadId newUploadId() {
|
||||
return UploadId.of(UUID.randomUUID());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Domain-agnostic UUID conversion utility. See README for the design rationale. */
|
||||
public final class UuidCodec {
|
||||
|
||||
private UuidCodec() {}
|
||||
|
||||
/**
|
||||
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
|
||||
* lowercase form; {@code null} input returns {@code null}.
|
||||
*
|
||||
* @throws IllegalArgumentException on a malformed UUID
|
||||
*/
|
||||
public static String normalize(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return UUID.fromString(input).toString();
|
||||
}
|
||||
|
||||
public static UUID toUuid(String uuidString) {
|
||||
return UUID.fromString(uuidString);
|
||||
}
|
||||
|
||||
public static String fromUuid(UUID uuid) {
|
||||
return uuid.toString();
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Non-IO infrastructure-capability adapters (identifier generation/codec, clock, crypto/random).
|
||||
* See README for why these are separate from {@code adapter-outbound}.
|
||||
*/
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
class UuidCodecSpec extends Specification {
|
||||
|
||||
static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab"
|
||||
|
||||
def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() {
|
||||
expect:
|
||||
UuidCodec.normalize(input) == CANONICAL
|
||||
|
||||
where:
|
||||
label | input
|
||||
"이미 canonical 인 입력" | CANONICAL
|
||||
"대문자 입력" | CANONICAL.toUpperCase()
|
||||
}
|
||||
|
||||
def "normalize 는 null 입력에 대해 null 을 반환한다"() {
|
||||
expect:
|
||||
UuidCodec.normalize(null) == null
|
||||
}
|
||||
|
||||
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
|
||||
when:
|
||||
UuidCodec.normalize("not-a-uuid")
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() {
|
||||
given:
|
||||
def uuid = UuidCodec.toUuid(CANONICAL)
|
||||
|
||||
expect:
|
||||
UuidCodec.fromUuid(uuid) == CANONICAL
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.outbound.identifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HmacUserPrincipalPseudonymizerTest {
|
||||
|
||||
private static final byte[] SALT_A =
|
||||
"test-salt-A-32-bytes-padding-xxx".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] SALT_B =
|
||||
"test-salt-B-32-bytes-padding-yyy".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructor guard tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nullSaltThrowsIllegalArgumentException() {
|
||||
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySaltThrowsIllegalArgumentException() {
|
||||
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(new byte[0]))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Null / blank input → null output
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nullInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize(" ")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyStringInputReturnsNull() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
assertThat(pseudonymizer.pseudonymize("")).isNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void sameInputSameSaltProducesSameOutputOnSameInstance() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String first = pseudonymizer.pseudonymize("user-123");
|
||||
String second = pseudonymizer.pseudonymize("user-123");
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameInputSameSaltProducesSameOutputAcrossTwoInstances() {
|
||||
var p1 = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
var p2 = new HmacUserPrincipalPseudonymizer(SALT_A.clone());
|
||||
assertThat(p1.pseudonymize("user-abc")).isEqualTo(p2.pseudonymize("user-abc"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Salt-sensitivity
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void differentSaltProducesDifferentOutput() {
|
||||
var pA = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
var pB = new HmacUserPrincipalPseudonymizer(SALT_B);
|
||||
assertThat(pA.pseudonymize("user-xyz")).isNotEqualTo(pB.pseudonymize("user-xyz"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// One-way property (output != input, output does not contain input)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void outputDoesNotEqualRawInput() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String raw = "alice@example.com";
|
||||
assertThat(pseudonymizer.pseudonymize(raw)).isNotEqualTo(raw);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outputDoesNotContainRawInputAsSubstring() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String raw = "alice";
|
||||
assertThat(pseudonymizer.pseudonymize(raw)).doesNotContain(raw);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Output format: lowercase hex, exactly 64 characters (256-bit HMAC)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void outputMatchesLowercaseHex64CharPattern() {
|
||||
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
|
||||
String token = pseudonymizer.pseudonymize("some-user");
|
||||
assertThat(token).matches("^[0-9a-f]{64}$");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user