feat(profile-service): Go profile service with contract parity tests [JDWLABS-481] - #225
Open
jdwillmsen wants to merge 6 commits into
Open
feat(profile-service): Go profile service with contract parity tests [JDWLABS-481]#225jdwillmsen wants to merge 6 commits into
jdwillmsen wants to merge 6 commits into
Conversation
| @@ -0,0 +1,15 @@ | |||
| # Distroless final image; consumes the Nx build output at ./dist/... | |||
| @@ -0,0 +1,21 @@ | |||
| # Self-contained build for local iteration (no prior `nx build` needed). | |||
| @@ -0,0 +1,74 @@ | |||
| module apps/backend/profile-service | |||
| @@ -0,0 +1,74 @@ | |||
| module apps/backend/profile-service | |||
| @@ -0,0 +1,74 @@ | |||
| module apps/backend/profile-service | |||
The profile half of the usersrole split, built against the frozen contract
rather than against the springdoc document, and authorizing through the shared
auth library with no per-endpoint reimplementation.
Three things could not be transcribed and had to be built:
net/http's ServeMux cannot express the routing. It refuses any two patterns
where neither matches a strict subset of the other, and
/api/profiles/by-user/{userId} against /api/profiles/{profileId}/icon is exactly
that shape — they overlap on /api/profiles/by-user/icon and neither contains the
other, so registering both panics and the service would not start. The router
here decides them as PathPattern.SPECIFICITY_COMPARATOR does, fewest captures
then longest normalized pattern, and refuses a genuine tie at construction:
Spring discovers that case at request time and answers 500 to whoever asked.
Every expectation in its suite is transcribed from the contract's measured
routing rather than reasoned from the comparator.
The CORS layer is reproduced from SecurityConfig's CorsConfiguration and mounted
outside authentication, matching the JVM filter order. A browser puts no
Authorization header on a preflight, so a preflight that reached the
authentication layer would be refused and every cross-origin call from the
frontends would fail at cutover with the request itself perfectly valid.
The wire formats are Jackson's, not Go's defaults: a plain calendar date for
birthdate, an ISO-8601 stamp rendered in UTC whatever the host zone, an empty
array rather than null for an unpopulated address set. Each is pinned by the
frontends' own Profile type and fixtures.
Storage is set-based. ProfileRepositoryImpl issues two extra queries per row, so
a hundred-row page costs it two hundred round trips; the reads here fill in the
addresses and icon of a whole page in one query each. The create's existence
check moves to the foreign key on auth.profiles.user_id, which is stronger than
the application read it replaces and free on the request path, and the caller
still sees a 404 naming the missing user rather than a 500.
Behaviours frozen deliberately, because a client keyed on them would change at
at cutover, adding an address answers 200 with the parent profile, replacing
icon on a profile that has none answers 500, and deleting a profile that does
not exist answers 204. One is corrected instead: replacing an icon stamps
modified_by_user_id with the acting user, where the JVM passes the icon's
original creator — a false audit record in a column this service's own response
exposes.
The suites are three. Authorization parity drives every operation against every
principal its rule admits and refuses, with the rules read from the contract's
x-authorization values rather than restated. Contract drift compares the served
route set against the document in both directions, so a route this service
serves that the document does not describe fails as loudly as the reverse.
Storage and end-to-end run the deployed 00_schema.sql in a Postgres container,
covering the address delete scoping, the icon caps and download, the pagination
clamps and the JSON shapes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
The release set is allowlist-driven — a project absent from nx.json release.projects is never versioned and never delivered — and the deliver matrix reads its targets straight off the project graph, so the entry and the target set together are what make CI publish an image. The image runs as a numeric uid rather than the base tag's nonroot name. A Kubernetes runAsNonRoot check has to resolve a named user against the image's own /etc/passwd, and a previous deployment here failed that check on an image whose USER was a name. Two departures from the servicediscovery precedent, both deliberate: build-image drops the inherited ^build. This project declares implicit dependencies on usersrole and authdb because its tests read their frozen contract and their schema, which is what keeps a change to either marking this project affected; the image contains neither, so making delivery wait on a Gradle build of the Spring application would buy nothing and risk the release. There is no update-app target. It would open a pull request against charts/profile-service in the deployments repo, and that chart does not exist yet — the first release would fail on it. The target belongs with the chart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
…en quirks The environment table is the one the chart work downstream reads: the datasource and the signing key keep the names usersrole reads, so one chart value feeds both services through the cutover, and only what is genuinely this process's own carries a new prefix. Also records the behaviours reproduced rather than corrected, and the one corrected rather than reproduced, so a reviewer comparing the two services does not have to rediscover which is which. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
…now sends The two refusal shapes are not the same shape, and the parity suite previously asserted only the status and the reason header for a 403 because the library's writer sent no body and the correction was in flight. It has landed, so the suite exercises the real thing: Content-Type, the container error fields, and the absent message key that server.error.include-message being never produces rather than an empty one. The path field gets its own end-to-end case, because it is the field read when a refusal turns into a support ticket and the only one whose value depends on the request rather than on the status. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
jdwillmsen
force-pushed
the
feat/JDWLABS-481-profile-service-go
branch
from
September 6, 2026 04:44
b098e54 to
f10ee05
Compare
Three CodeQL alerts and four dependency advisories, none of which needed a tradeoff. The pool sizes are parsed at the width they are stored at. Reading them as int and converting to int32 truncates a value above 2^31 into a plausible small pool on a 64-bit host instead of falling back, so the bound belongs to the parser rather than to a check after the fact. The metrics wrapper no longer wraps Write. It only ever needed the status, and a handler that writes a body without setting one leaves the field at the 200 the standard library would have sent anyway — so the override changed nothing and put a second copy of every response body on a path with no reason to see one. CodeQL read that copy as a response sink outside the Content-Type its writer sets, and reported every decoded request body as reflected XSS. golang.org/x/crypto and github.com/moby/go-archive arrive through testcontainers-go and carried a critical and a high advisory respectively. Both are test-only and neither reaches the built image, but a version with a known fix available is not worth keeping for that reason alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
…kspace can build v0.56.0 declares `go 1.26.0`, which raises this module's own floor above the `go 1.26` the workspace declares, and every tool that loads the workspace from the repository root then refuses to build it — CodeQL's extractor first among them. Raising go.work instead would pin the toolchain the whole repository resolves through actions/setup-go, which is a repository-wide decision and not this project's to make in passing. v0.55.0 carries the fix for the critical advisory that prompted the bump and declares `go 1.25.0`. Two advisories against it remain, both reported without a severity: one has a fix only in v0.56.0 and the other has no fixed version at all, so no reachable version clears both. Neither is reachable from this service in any case — x/crypto arrives through testcontainers-go and nothing in the built image links it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
jdwillmsen
force-pushed
the
feat/JDWLABS-481-profile-service-go
branch
from
September 6, 2026 05:13
22b3ba7 to
3b515bf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
apps/backend/profile-service: the profile half of theusersrolesplit as a Goapplication, serving all fifteen
/api/profilesoperations against the sameauthschema, built and released as an image only. No traffic is routed here.Layout
Plumbing:
go.workentry,nx.jsonrelease.projectsentry,project.jsonwith build / serve / test / lint / tidy / build-image / local-build-image /
serve-container / update-description / download, a distroless
Dockerfileand aself-contained
Dockerfile.local, and both READMEs.Parity evidence
nx test profile-service— 87 tests,go test -race, green.go vetclean vianx lint;golangci-lint run ./...reports 0 issues.nx buildand aDockerfile.localimage build both succeed.Contract-driven, not restated.
contract_test.goscansprofile-service.openapi.yamlfor its paths, methods,operationIds andx-authorization.rulevalues, then asserts:/api/profilesand not described fails as loudly as one described and not served
operation the document does not describe
libs/backend/shared/auth/authzdecides, and has atleast one principal that must pass it and one that must fail it
Authorization outcomes. Every operation is driven against every principal
its rule admits and refuses, with tokens minted by
authtest.Minter:ADMINADMIN_OR_SELF_BY_BODY_USER_IDuserId== claimADMIN_OR_SELF_BY_USER_IDADMIN_OR_SELF_BY_PROFILE_IDprofile_idclaimPlus, for all fifteen: no token is 401, a tampered signature is 401, and the
profile_idfallback is shown to key on theuser_idclaim rather than the path(a caller with no claim asking for someone else's profile is still 403; a caller
with no profile at all is 403, not admitted).
Against real storage. Testcontainers-go loads the deployed
apps/database/authdb/src/00_schema.sql, one container shared across thepackage. Covered: the address delete scoping and its two distinct not-found
messages, the 2 MB multipart cap at and just under the limit, the wrong part
name, the
image/pngdownload round trip, the icon conflict and delete, thepagination clamps (
page=-5,size=0,size=100000, non-numeric → 400), anempty page as
[]rather thannull, the profile delete clearing addresses andicons, and the wire shape of a profile end to end.
Measured. Idle RSS of the built container against a live Postgres: 4.5
MiB (
docker stats, no requests served). Health, the 401 shape and the CORSpreflight were exercised against that running container, not only in tests.
Refusal shapes
Refusals go through the shared library's writers; this service composes no 401 or
403 body of its own. Rebased onto the merged
WriteForbiddencorrection, so theparity suite exercises the real writer for both shapes: an empty 401 with no
Content-Type, and a 403 carryingContent-Type: application/jsonand Boot's{timestamp, status, error, path}with nomessagekey. Thepathfieldgets its own end-to-end case against a live route.
What the chart will need
jdwlabs/profile-service, port 8080,USER 65532:65532.usersrole's existing values:UR_JWT_SECRET_KEY,UR_PG_DATASOURCE_URL(the JDBC form, translated at startup),UR_PG_USERNAME,UR_PG_PASSWORD. Same secret, same datasource block, nosecond set.
PS_JWT_ISSUER_ORIGIN(required — the servicerefuses to start without it, or without
PS_JWT_ALLOW_ANY_ISSUER_AND_AUDIENCE=truesaid out loud),PS_PORT,PS_DB_MAX_CONNECTIONS/PS_DB_MIN_CONNECTIONS, the threePS_CORS_ALLOWED_*lists,PS_SHUTDOWN_TIMEOUT_SECONDS./actuator/health, ServiceMonitor on/actuator/prometheus— thepaths the JVM serves, so
charts/usersrole's blocks carry over. Notecharts/servicediscoveryhas neither; this needsusersrole's.http_server_requests_seconds{method,uri,status,outcome}, so theexisting p50/p95/p99 panels keep working. Bucket edges differ: Micrometer
generates its own from percentile config and cannot be reproduced edge for
edge, so an interpolated quantile is close rather than identical.
update-apptarget here, deliberately — see below.Decisions worth reviewing
origin:profile-service-0.0.0, onc9e54090(the commit this branch was cut from).
nx releaserefuses to version aproject in
release.projectswith no matching tag and exits 1, which wouldfail the PR's own Release Config Dry Run check and then the release itself.
This is the procedure the error message names and the precedent
ai-sre-relay-0.1.0set. The dry run now resolves0.0.0 → 0.0.1.update-apptarget. It would open a PR againstcharts/profile-service/Chart.yamlin the deployments repo; that chart doesnot exist yet, so the first release would fail on it. It belongs with the
chart.
build-imagedrops the inherited^build. The project declares implicitdependencies on
usersroleandauthdbso a change to the frozen contract orthe schema marks it affected and re-runs the drift check; the image contains
neither, and making delivery wait on a Gradle build of the Spring application
would buy nothing and risk the release.
ServeMux.ServeMuxpanics on/api/profiles/by-user/{userId}against/api/profiles/{profileId}/icon—they overlap on
/api/profiles/by-user/iconand neither is more specific — sothe service would not start. The router reproduces
PathPattern.SPECIFICITY_COMPARATORand refuses a genuine tie atconstruction, turning the 500 Spring answers at request time into a failure to
boot. Its expectations are transcribed from the contract's measured routing.
modified_by_user_idwith the acting user.ProfileIconDaoPostgres.updatepassesprofileIcon.createdByUserId()whereProfileService.updateIconsupplied the actor, so the JVM records the icon'soriginal creator as the author of every replacement. That is a false audit
record in a column this service's own response exposes; reproducing it was not
defensible. Flagged rather than assumed agreed.
USER 65532:65532, numeric.github-repo-health-exporterusesUSER nonroot:nonroot; a named user has to be resolved against the image's/etc/passwdfor arunAsNonRootcheck, and that broke a deployment herebefore.
Where the contract and the Java disagree
Both found while transcribing, both resolved in favour of the code, and neither
requiring a contract edit — recorded here rather than in
docs/contracts, whichanother change owns right now:
one.
deleteAddressraisesAddress not found with id {addressId} for profile with id {profileId}, which the contract quotes.updateAddressraises
Address not found with id {addressId}— no profile — and thecontract does not say so, describing that 404 only through the shared
NotFoundresponse. Both are reproduced exactly and both are asserted end toend.
x-icon-identifiersays the iconidis never used for a lookup; thereplacement still filters on it. The contract freezes
profile_idas theicon's only identifier, and every route does. But
ProfileIconDaoPostgres.updatefilters
WHERE icon_id = :id, and that is the statement a replacement runs.Kept as-is here, filtering on
icon_idafter resolving it fromprofile_id,so a profile that somehow carries two rows has exactly one replaced rather
than both — the invariant is still only application-enforced, since
profile_icons_profile_id_idxis a plain index.Also worth naming, though the contract does say it:
x-authority-freshnessandx-stale-profile-claimdescribe real widenings this service inherits byauthorizing from the token. Nothing here narrows or widens them further.
Security findings
All 22 checks are green. Fixed rather than argued away:
go/incorrect-integer-conversionx2 (high) — the pool sizes now parse atthe width they are stored at. Reading as
intand converting toint32truncates a value above 2^31 into a plausible small pool on a 64-bit host
instead of falling back.
go/reflected-xss(high) — the metrics wrapper no longer overridesWrite. It only ever needed the status, and a handler that writes withoutsetting one leaves the field at the 200 the standard library would have sent
anyway, so the override changed nothing and put a second copy of every
response body on a path with no reason to see one. CodeQL read that copy as a
sink outside the
Content-Typeits writer sets, and reported every decodedrequest body as reflected XSS.
golang.org/x/cryptoto v0.55.0 and
github.com/moby/go-archiveto v0.3.0.Five Trivy findings stay open, all against this project, none blocking (the
scan workflow keeps Trivy non-blocking on purpose) and each with a reason:
GO-2026-5932,CVE-2026-78662,CVE-2026-56855—golang.org/x/crypto,all reported without a severity. v0.56.0 would clear two of the three and
GO-2026-5932has no fixed version at all, so no reachable version clears theset; and v0.56.0 declares
go 1.26.0, which lifts this module's floor abovethe
go 1.26the workspace declares and breaks every tool that loads theworkspace from the repository root, CodeQL's extractor included. Raising
go.workinstead pins the toolchain the whole repository resolves throughactions/setup-go— a repository-wide decision, not this project's to make inpassing.
x/cryptoarrives through testcontainers-go and nothing in the builtimage links it.
DS-0026x2 (low), "Add HEALTHCHECK" — the base is distroless: no shell,no
curl, nothing aHEALTHCHECKcould invoke but the service binary itself,which would mean adding a probe subcommand for the sake of an instruction
Kubernetes ignores outright. The chart uses real probes against
/actuator/health. The same finding sits open againstai-sre-relay/Dockerfile.local,github-repo-health-exporter/Dockerfileandits
.local— this is the repository's existing posture, not a new one.Not done here
Helm chart, ArgoCD wiring, routing — the deployments ticket. The JVM profile code
stays live and authoritative.
JDWLABS-481
🤖 Generated with Claude Code
https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY