Skip to content

feat(profile-service): Go profile service with contract parity tests [JDWLABS-481] - #225

Open
jdwillmsen wants to merge 6 commits into
mainfrom
feat/JDWLABS-481-profile-service-go
Open

feat(profile-service): Go profile service with contract parity tests [JDWLABS-481]#225
jdwillmsen wants to merge 6 commits into
mainfrom
feat/JDWLABS-481-profile-service-go

Conversation

@jdwillmsen

@jdwillmsen jdwillmsen commented Sep 6, 2026

Copy link
Copy Markdown
Member

What

apps/backend/profile-service: the profile half of the usersrole split as a Go
application, serving all fifteen /api/profiles operations against the same
auth schema, built and released as an image only. No traffic is routed here.

Layout

apps/backend/profile-service/     module apps/backend/profile-service, flat package
├── router.go        Spring's path specificity, which net/http's ServeMux cannot express
├── cors.go          SecurityConfig's CorsConfiguration, mounted outside authentication
├── server.go        layer order: CORS, logging, metrics, auth, router
├── handlers.go      the fifteen operations, each declaring its contract rule
├── store.go         auth.profiles, auth.addresses, auth.profile_icons, set-based
├── model.go         Jackson's wire formats, not Go's defaults
├── errors.go        one writer per failure GlobalExceptionHandler maps
├── metrics.go       http_server_requests_seconds, as Micrometer names it
├── config.go        environment resolved once, at startup
└── main.go          pool, listener, graceful shutdown

Plumbing: go.work entry, nx.json release.projects entry, project.json
with build / serve / test / lint / tidy / build-image / local-build-image /
serve-container / update-description / download, a distroless Dockerfile and a
self-contained Dockerfile.local, and both READMEs.

Parity evidence

nx test profile-service — 87 tests, go test -race, green. go vet clean via
nx lint; golangci-lint run ./... reports 0 issues. nx build and a
Dockerfile.local image build both succeed.

Contract-driven, not restated. contract_test.go scans
profile-service.openapi.yaml for its paths, methods, operationIds and
x-authorization.rule values, then asserts:

  • the document still describes exactly 15 authorized operations, all under /api/profiles
  • the served route set equals that set in both directions — a route served
    and not described fails as loudly as one described and not served
  • every operation is served under the rule the document names
  • every operation is driven by a parity case, and no parity case drives an
    operation the document does not describe
  • every rule named is one libs/backend/shared/auth/authz decides, and has at
    least 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:

Rule Operations Allowed Denied
ADMIN 1 ADMIN owner, stranger
ADMIN_OR_SELF_BY_BODY_USER_ID 1 ADMIN, body userId == claim stranger
ADMIN_OR_SELF_BY_USER_ID 3 ADMIN, owner stranger
ADMIN_OR_SELF_BY_PROFILE_ID 10 ADMIN, owner, owner with no profile_id claim stranger

Plus, for all fifteen: no token is 401, a tampered signature is 401, and the
profile_id fallback is shown to key on the user_id claim 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 the
package. 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/png download round trip, the icon conflict and delete, the
pagination clamps (page=-5, size=0, size=100000, non-numeric → 400), an
empty page as [] rather than null, the profile delete clearing addresses and
icons, 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 CORS
preflight 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 WriteForbidden correction, so the
parity suite exercises the real writer for both shapes: an empty 401 with no
Content-Type, and a 403 carrying Content-Type: application/json and Boot's
{timestamp, status, error, path} with no message key. The path field
gets its own end-to-end case against a live route.

What the chart will need

  • Image jdwlabs/profile-service, port 8080, USER 65532:65532.
  • Reuse 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, no
    second set.
  • New, this service's own: PS_JWT_ISSUER_ORIGIN (required — the service
    refuses to start without it, or without
    PS_JWT_ALLOW_ANY_ISSUER_AND_AUDIENCE=true said out loud), PS_PORT,
    PS_DB_MAX_CONNECTIONS / PS_DB_MIN_CONNECTIONS, the three
    PS_CORS_ALLOWED_* lists, PS_SHUTDOWN_TIMEOUT_SECONDS.
  • Probes on /actuator/health, ServiceMonitor on /actuator/prometheus — the
    paths the JVM serves, so charts/usersrole's blocks carry over. Note
    charts/servicediscovery has neither; this needs usersrole's.
  • Metrics are http_server_requests_seconds{method,uri,status,outcome}, so the
    existing 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.
  • No update-app target here, deliberately — see below.

Decisions worth reviewing

  • A seed tag was pushed to origin: profile-service-0.0.0, on c9e54090
    (the commit this branch was cut from). nx release refuses to version a
    project in release.projects with no matching tag and exits 1, which would
    fail 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.0 set. The dry run now resolves 0.0.0 → 0.0.1.
  • No update-app target. It would open a PR against
    charts/profile-service/Chart.yaml in the deployments repo; that chart does
    not exist yet, so the first release would fail on it. It belongs with the
    chart.
  • build-image drops the inherited ^build. The project declares implicit
    dependencies on usersrole and authdb so a change to the frozen contract or
    the 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.
  • A router rather than ServeMux. ServeMux panics on
    /api/profiles/by-user/{userId} against /api/profiles/{profileId}/icon
    they overlap on /api/profiles/by-user/icon and neither is more specific — so
    the service would not start. The router reproduces
    PathPattern.SPECIFICITY_COMPARATOR and refuses a genuine tie at
    construction, turning the 500 Spring answers at request time into a failure to
    boot. Its expectations are transcribed from the contract's measured routing.
  • One behaviour corrected rather than frozen. Replacing an icon stamps
    modified_by_user_id with the acting user.
    ProfileIconDaoPostgres.update passes profileIcon.createdByUserId() where
    ProfileService.updateIcon supplied the actor, so the JVM records the icon's
    original 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-exporter uses
    USER nonroot:nonroot; a named user has to be resolved against the image's
    /etc/passwd for a runAsNonRoot check, and that broke a deployment here
    before.

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, which
another change owns right now:

  1. The two address not-found messages differ, and the contract only shows
    one.
    deleteAddress raises Address not found with id {addressId} for profile with id {profileId}, which the contract quotes. updateAddress
    raises Address not found with id {addressId} — no profile — and the
    contract does not say so, describing that 404 only through the shared
    NotFound response. Both are reproduced exactly and both are asserted end to
    end.
  2. x-icon-identifier says the icon id is never used for a lookup; the
    replacement still filters on it.
    The contract freezes profile_id as the
    icon's only identifier, and every route does. But ProfileIconDaoPostgres.update
    filters WHERE icon_id = :id, and that is the statement a replacement runs.
    Kept as-is here, filtering on icon_id after resolving it from profile_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_idx is a plain index.

Also worth naming, though the contract does say it: x-authority-freshness and
x-stale-profile-claim describe real widenings this service inherits by
authorizing 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-conversion x2 (high) — the pool sizes now parse at
    the width they are stored at. Reading 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.
  • go/reflected-xss (high) — the metrics wrapper no longer overrides
    Write. It only ever needed the status, and a handler that writes 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
    sink outside the Content-Type its writer sets, and reported every decoded
    request body as reflected XSS.
  • CVE-2026-56854 (critical) and CVE-2026-17106 (high)golang.org/x/crypto
    to v0.55.0 and github.com/moby/go-archive to 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-56855golang.org/x/crypto,
    all reported without a severity. v0.56.0 would clear two of the three and
    GO-2026-5932 has no fixed version at all, so no reachable version clears the
    set; and v0.56.0 declares go 1.26.0, which lifts this module's floor above
    the go 1.26 the workspace declares and breaks every tool that loads the
    workspace from the repository root, CodeQL's extractor included. Raising
    go.work instead pins the toolchain the whole repository resolves through
    actions/setup-go — a repository-wide decision, not this project's to make in
    passing. x/crypto arrives through testcontainers-go and nothing in the built
    image links it.
  • DS-0026 x2 (low), "Add HEALTHCHECK" — the base is distroless: no shell,
    no curl, nothing a HEALTHCHECK could 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 against
    ai-sre-relay/Dockerfile.local, github-repo-health-exporter/Dockerfile and
    its .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

Comment thread apps/backend/profile-service/go.mod Fixed
Comment thread apps/backend/profile-service/go.mod Fixed
@@ -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
Comment thread apps/backend/profile-service/config.go Fixed
Comment thread apps/backend/profile-service/config.go Fixed
Comment thread apps/backend/profile-service/metrics.go Fixed
jdwillmsen and others added 4 commits September 6, 2026 04:42
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
jdwillmsen force-pushed the feat/JDWLABS-481-profile-service-go branch from b098e54 to f10ee05 Compare September 6, 2026 04:44
jdwillmsen and others added 2 commits September 6, 2026 04:55
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
jdwillmsen force-pushed the feat/JDWLABS-481-profile-service-go branch from 22b3ba7 to 3b515bf Compare September 6, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants