Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 576d733eae feat(tenants): provision the product tenant with the registry UUID on create
ci / shared (pull_request) Successful in 14s
ci / test (pull_request) Failing after 16m9s
ci / image (pull_request) Skipped
Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips.

The registry is the authority for tenant identity (model B2), but nothing ever
told the product about a new tenant. The anchors drifted: the registry held
acme/matrix-acme while the product database held only the legacy seed tenant
9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and
would 403 EVERY authenticated request, because no real user's org slug existed
locally.

New internal/product port, mirroring keycloak.Adapter: handlers depend on the
interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and
NoopProvisioner otherwise, so an unconfigured deployment still creates tenants.
Tenant creation now also provisions the product tenant with OUR uuid, using the
same best-effort contract as Keycloak provisioning: a failure does not roll the
tenant back, it emits a product.provision_failed audit event so the divergence
is traceable. Success emits product.tenant_provisioned.

A 409 from the product counts as success — onboarding may retry and the
product's insert is idempotent on the primary key (compliance#217).

Server.productProvisioner() guarantees the documented never-nil invariant;
tests construct Server directly and would otherwise panic mid-tenant-creation.

5 tests incl. the point of the whole port (the registry UUID is what gets sent).
Full suite green with -race, coverage 71.4% (gate 70).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
2026-09-01 23:16:33 +02:00
sharang 80565fdbf2 fix(keycloak): org create must send a domain (KC 26 rejects domainless) (#20)
ci / shared (push) Successful in 12s
ci / test (push) Successful in 21m24s
ci / image (push) Successful in 17s
2026-09-01 09:18:15 +00:00
sharang 84516e9b4f feat(keycloak): organizations become the membership authority source (#19)
ci / shared (push) Successful in 11s
ci / test (push) Successful in 21m22s
ci / image (push) Successful in 19s
2026-09-01 09:03:23 +00:00
sharang 4eaee521d4 fix(build): builder image Go 1.25, go.mod requires >= 1.25.0 (#18)
ci / shared (push) Successful in 14s
ci / test (push) Successful in 21m24s
ci / image (push) Successful in 27s
2026-08-31 17:18:10 +00:00
sharang 03b479ab6f fix(ci): image job needs a docker CLI, build in docker:27-cli (#17)
ci / shared (push) Successful in 12s
ci / test (push) Successful in 21m18s
ci / image (push) Failing after 6s
2026-08-31 15:51:36 +00:00
sharang 583d8f8f25 ci: the image job never ran — fix condition, tag :latest, deploy for real (#16)
ci / shared (push) Successful in 13s
ci / test (push) Successful in 21m20s
ci / image (push) Failing after 11s
2026-08-30 21:10:33 +00:00
sharang 739b19341c fix(deps): clear the HIGH/CRITICAL CVEs failing the trivy CI gate (#15)
ci / shared (push) Successful in 13s
ci / test (push) Successful in 21m13s
ci / image (push) Canceled after 0s
2026-08-25 08:49:01 +00:00
sharangandSharang Parnerkar ce75ed04c2 RBAC Phase 1: membership authority endpoint + fail-closed API auth (#14)
ci / shared (push) Failing after 14s
ci / test (push) Successful in 21m13s
ci / image (push) Skipped
Implements the model-B2 membership authority (ratified compliance auth design) and inbound token verification.

- GET /v1/users/{id}/memberships: Keycloak supplies user->tenant links + org_roles (attribute projection until the realm migrates to Organizations); registered tenants override status/plan/products from registry tables (source: registry|keycloak). Closes the M5.2-deferred org_roles stub.
- New internal/authn: OIDC discovery + JWKS verification (go-oidc/v3), audience tenant-registry. /healthz + /readyz stay PUBLIC_EXPLICIT; all other routes INTERNAL_SERVICE_ONLY. AUTH_ENABLED=true refuses to start on incomplete config (fail-closed); false (dev default) keeps current behavior.
- Full suite green incl. postgres testcontainers; openapi.yaml updated (contract test passes).

Activation is a separate step (Auth-5): requires the orca-infra env merge and a portal service token (portal currently calls with no auth).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #14
2026-08-24 18:21:46 +00:00
15 changed files with 520 additions and 129 deletions
+46 -22
View File
@@ -94,29 +94,53 @@ jobs:
image: image:
needs: [shared, test] needs: [shared, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('Dockerfile') != '' # NOTE: no hashFiles() here — at job level it evaluates BEFORE checkout
# against an empty workspace, so the old condition was always false and
# this job silently never ran (deployment sat on an Aug-06 image).
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker runs-on: docker
# The runner's default job container has no docker CLI, so
# docker/login-action + docker/build-push-action die with "Unable to
# locate executable file: docker". Same pattern as the proven
# breakpilot-compliance build-push-deploy.yml: run in docker:27-cli
# (talks to the runner's daemon) and use plain docker commands.
container: docker:27-cli
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
run: |
apk add --no-cache git curl openssl
git clone --depth 1 --branch ${GITHUB_REF_NAME} $(echo ${GITHUB_SERVER_URL} | sed -E "s#^(https?://)#\1ci:${{ secrets.GITHUB_TOKEN }}@#")/${GITHUB_REPOSITORY}.git .
- uses: docker/login-action@v3 - name: Login
with:
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
- uses: anchore/sbom-action@v0
with:
image: repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
- name: orca deploy stage
run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
env: env:
ORCA_TOKEN: ${{ secrets.ORCA_STAGE_TOKEN }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
run: echo "$REGISTRY_PASS" | docker login repo.breakpilot.com -u "$REGISTRY_USER" --password-stdin
- name: Build + push
run: |
set -eu
docker build \
-t repo.breakpilot.com/breakpilot/tenant-registry:latest \
-t repo.breakpilot.com/breakpilot/tenant-registry:sha-${GITHUB_SHA} \
.
docker push repo.breakpilot.com/breakpilot/tenant-registry:latest
docker push repo.breakpilot.com/breakpilot/tenant-registry:sha-${GITHUB_SHA}
# Notify orca to pull :latest and recreate the service (same pattern as
# breakpilot-core/build-pitch-deck.yml). The stage-env deploy this used to
# attempt does not exist in this cluster.
- name: orca deploy webhook
env:
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
run: |
set -eu
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"${GITHUB_SHA}\",\"message\":\"ci: tenant-registry image build\"}}"
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
curl -sSf -k \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$PAYLOAD" \
"$ORCA_WEBHOOK_URL"
+1 -1
View File
@@ -3,7 +3,7 @@
# /tenant-registry — long-running API server # /tenant-registry — long-running API server
# /migrate — one-shot schema migrator (Orca init container in prod) # /migrate — one-shot schema migrator (Orca init container in prod)
FROM golang:1.24-alpine AS build FROM golang:1.25-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+14 -1
View File
@@ -13,6 +13,7 @@ import (
"gitea.meghsakha.com/platform/tenant-registry/internal/authn" "gitea.meghsakha.com/platform/tenant-registry/internal/authn"
"gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
"gitea.meghsakha.com/platform/tenant-registry/internal/server" "gitea.meghsakha.com/platform/tenant-registry/internal/server"
"gitea.meghsakha.com/platform/tenant-registry/internal/store" "gitea.meghsakha.com/platform/tenant-registry/internal/store"
) )
@@ -74,7 +75,19 @@ func main() {
slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)") slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)")
} }
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av}) // Downstream product provisioning. Wired only when PRODUCT_API_URL is set;
// otherwise a no-op so an unconfigured deployment still creates tenants.
// The product's gate is not yet enforcing, so no token is attached today —
// AuthHeaderFunc is the seam for when it is.
var pv product.Provisioner = product.NoopProvisioner{}
if cfg.ProductAPIURL != "" {
pv = product.NewHTTPProvisioner(cfg.ProductAPIURL, cfg.ProductAPIPath, nil, cfg.ProductTimeout)
slog.Info("product provisioner configured", "url", cfg.ProductAPIURL, "path", cfg.ProductAPIPath)
} else {
slog.Warn("PRODUCT_API_URL not set — product tenant provisioning disabled (anchors may diverge)")
}
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av, Product: pv})
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: handler, Handler: handler,
+13 -18
View File
@@ -4,14 +4,14 @@ go 1.25.0
require ( require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/getkin/kin-openapi v0.138.0 github.com/getkin/kin-openapi v0.144.0
github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-jose/go-jose/v4 v4.1.4
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.55.0
) )
require ( require (
@@ -34,33 +34,29 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.3.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/oasdiff/yaml3 v0.0.12 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
@@ -70,7 +66,6 @@ require (
github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -78,8 +73,8 @@ require (
go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+30 -42
View File
@@ -43,8 +43,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -54,12 +54,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -81,10 +81,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -95,42 +93,36 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -161,10 +153,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -181,21 +169,21 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+11
View File
@@ -27,6 +27,13 @@ type Config struct {
// contains AuthAudience. // contains AuthAudience.
AuthEnabled bool AuthEnabled bool
AuthAudience string AuthAudience string
// Downstream product provisioning. When ProductAPIURL is set, tenant
// creation also provisions the tenant in the product's own database with
// the SAME registry UUID, so both anchors agree. Empty ⇒ no-op adapter.
ProductAPIURL string
ProductAPIPath string
ProductTimeout time.Duration
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -49,6 +56,10 @@ func Load() (*Config, error) {
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true", AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"), AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
ProductAPIURL: os.Getenv("PRODUCT_API_URL"),
ProductAPIPath: getenv("PRODUCT_API_TENANTS_PATH", "/sdk/v1/tenants"),
ProductTimeout: 10 * time.Second,
}, nil }, nil
} }
+6 -5
View File
@@ -75,11 +75,12 @@ type Adapter interface {
SyncClaims(ctx context.Context, userID string, c Claims) error SyncClaims(ctx context.Context, userID string, c Claims) error
// Memberships resolves the tenants user userID (the Keycloak user id, // Memberships resolves the tenants user userID (the Keycloak user id,
// i.e. the JWT `sub`) belongs to, as Keycloak records them. The realm // i.e. the JWT `sub`) belongs to, as Keycloak records them. Since the
// has no Organizations yet, so this reads the user's attribute // realm enabled Organizations (2026-09-01) this is an org-membership
// projection — zero or one memberships. When the realm migrates to // query: one membership per enabled org the user belongs to (alias =
// Organizations this becomes an org-membership query and callers keep // tenant slug, org attribute tenant_id = registry UUID). The legacy
// working unchanged. Returns ErrUserNotFound for an unknown user id. // user-attribute projection no longer grants membership on its own.
// Returns ErrUserNotFound for an unknown user id.
Memberships(ctx context.Context, userID string) ([]Claims, error) Memberships(ctx context.Context, userID string) ([]Claims, error)
// Health pings the admin endpoint. Used by readyz and the cluster cold- // Health pings the admin endpoint. Used by readyz and the cluster cold-
+10
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -24,6 +25,7 @@ type stubKC struct {
emailCalls atomic.Int32 emailCalls atomic.Int32
healthCalls atomic.Int32 healthCalls atomic.Int32
syncCalls atomic.Int32 syncCalls atomic.Int32
lastOrgBody string
tokenFails atomic.Bool // when true, /token returns 401 once tokenFails atomic.Bool // when true, /token returns 401 once
} }
@@ -53,6 +55,8 @@ func newStubKC(t *testing.T) *stubKC {
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
s.orgCalls.Add(1) s.orgCalls.Add(1)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
s.lastOrgBody = string(body)
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz") w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
return return
@@ -135,6 +139,12 @@ func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
t.Errorf("call counts: org=%d user=%d member=%d email=%d", t.Errorf("call counts: org=%d user=%d member=%d email=%d",
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load()) s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
} }
// KC 26 rejects a domainless org; the adapter must send a synthetic
// per-slug domain so onboarding actually provisions.
if !strings.Contains(s.lastOrgBody, `"domains"`) ||
!strings.Contains(s.lastOrgBody, "acme.tenant.breakpilot.com") {
t.Errorf("org create body missing synthetic domain: %s", s.lastOrgBody)
}
} }
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) { func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
+15
View File
@@ -13,6 +13,11 @@ import (
// ─── organizations API ─────────────────────────────────────────────────── // ─── organizations API ───────────────────────────────────────────────────
// orgDomainSuffix namespaces the synthetic org domain. The slug is unique in
// the registry, so "<slug>.tenant.breakpilot.com" is unique per organization
// and never a real deliverable mail domain we might clash with.
const orgDomainSuffix = ".tenant.breakpilot.com"
type orgCreate struct { type orgCreate struct {
Name string `json:"name"` Name string `json:"name"`
Alias string `json:"alias"` Alias string `json:"alias"`
@@ -50,6 +55,16 @@ func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (*
Name: in.Name, Name: in.Name,
Alias: in.Slug, Alias: in.Slug,
Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID), Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID),
// Keycloak 26 rejects an organization with no domain ("You must
// provide at least one domain"). Membership is registry-authoritative
// (model B2), so we do NOT use Keycloak's email-domain auto-join; a
// synthetic per-tenant domain derived from the unique slug satisfies
// the constraint without depending on the customer's real mail domain
// (which may be a shared public domain and would collide across
// tenants). Unverified is fine — verification only gates auto-join.
Domains: []map[string]any{
{"name": in.Slug + orgDomainSuffix, "verified": false},
},
Attributes: map[string][]string{ Attributes: map[string][]string{
"tenant_id": {in.TenantID}, "tenant_id": {in.TenantID},
}, },
+52 -17
View File
@@ -18,7 +18,33 @@ type userRepresentation struct {
Attributes map[string][]string `json:"attributes"` Attributes map[string][]string `json:"attributes"`
} }
// Memberships implements Adapter against GET /admin/realms/{realm}/users/{id}. // memberOrgRepresentation is the slice of OrganizationRepresentation the
// membership query needs: the alias IS the tenant slug and the org
// attribute "tenant_id" carries the registry tenant UUID (both written by
// CreateOrgAndInvite, or provisioned by the realm admin for pre-existing
// tenants).
type memberOrgRepresentation struct {
ID string `json:"id"`
Alias string `json:"alias"`
Enabled bool `json:"enabled"`
Attributes map[string][]string `json:"attributes"`
}
// Memberships implements Adapter with Keycloak Organizations as the
// authoritative membership source (prod shape, realm orgs enabled
// 2026-09-01):
//
// 1. GET /users/{id} — preserves ErrUserNotFound semantics and supplies
// the per-user claim attributes (org_roles / products / plan /
// tenant_status) that SyncClaims maintains.
// 2. GET /organizations/members/{id}/organizations — the memberships.
//
// One Claims entry per ENABLED organization: TenantID = org attribute
// "tenant_id" (registry UUID), TenantSlug = org alias. A disabled org
// grants no membership. A user in no organization has zero memberships —
// the legacy user-attribute tenant projection is NO LONGER consulted for
// membership, so a stale tenant_id/tenant_slug user attribute cannot
// grant access the org model has revoked.
func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) { func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) {
var u userRepresentation var u userRepresentation
resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u) resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u)
@@ -33,25 +59,34 @@ func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims,
_ = resp.Body.Close() _ = resp.Body.Close()
return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode) return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode)
} }
return claimsFromAttributes(u.Attributes), nil
}
// claimsFromAttributes builds the membership list from a user's attribute var orgs []memberOrgRepresentation
// projection. A user with no tenant_id and no tenant_slug attribute simply resp, err = a.adminCall(
// has no memberships — that is a valid state, not an error. ctx, http.MethodGet, "/organizations/members/"+userID+"/organizations", nil, &orgs,
func claimsFromAttributes(attrs map[string][]string) []Claims { )
c := Claims{ if err != nil {
TenantID: attrValue(attrs, "tenant_id"), return nil, err
TenantSlug: attrValue(attrs, "tenant_slug"),
OrgRoles: attrValues(attrs, "org_roles"),
Products: attrValues(attrs, "products"),
Plan: attrValue(attrs, "plan"),
TenantStatus: attrValue(attrs, "tenant_status"),
} }
if c.TenantID == "" && c.TenantSlug == "" { if resp.StatusCode/100 != 2 {
return []Claims{} _ = resp.Body.Close()
return nil, fmt.Errorf("keycloak member organizations: %d", resp.StatusCode)
} }
return []Claims{c}
claims := []Claims{}
for _, org := range orgs {
if !org.Enabled {
continue
}
claims = append(claims, Claims{
TenantID: attrValue(org.Attributes, "tenant_id"),
TenantSlug: org.Alias,
OrgRoles: attrValues(u.Attributes, "org_roles"),
Products: attrValues(u.Attributes, "products"),
Plan: attrValue(u.Attributes, "plan"),
TenantStatus: attrValue(u.Attributes, "tenant_status"),
})
}
return claims, nil
} }
func attrValue(attrs map[string][]string, key string) string { func attrValue(attrs map[string][]string, key string) string {
+92 -21
View File
@@ -9,9 +9,13 @@ import (
"testing" "testing"
) )
// stubUsersKC is a users-endpoint-only KC look-alike; stubKC (client_test.go) // stubUsersKC is a users+organizations KC look-alike; stubKC (client_test.go)
// covers the org/invite paths and doesn't register GET /users/{id}. // covers the org-create/invite paths and doesn't register these reads.
func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Server { func stubUsersKC(
t *testing.T,
users map[string]userRepresentation,
memberOrgs map[string][]memberOrgRepresentation,
) *httptest.Server {
t.Helper() t.Helper()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
@@ -28,6 +32,14 @@ func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Se
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u) _ = json.NewEncoder(w).Encode(u)
}) })
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, r *http.Request) {
orgs, ok := memberOrgs[r.PathValue("id")]
if !ok {
orgs = []memberOrgRepresentation{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(orgs)
})
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
return srv return srv
@@ -40,10 +52,11 @@ func usersAdapter(srv *httptest.Server) *HTTPAdapter {
} }
func TestHTTPAdapter_Memberships(t *testing.T) { func TestHTTPAdapter_Memberships(t *testing.T) {
srv := stubUsersKC(t, map[string]userRepresentation{ users := map[string]userRepresentation{
"u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{ "u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
"tenant_id": {"acme-001"}, // legacy projection attrs — MUST NOT grant membership on their own
"tenant_slug": {"acme"}, "tenant_id": {"stale-legacy-001"},
"tenant_slug": {"stale"},
"tenant_status": {"active"}, "tenant_status": {"active"},
"plan": {"Scale"}, "plan": {"Scale"},
"org_roles": {"IT_ADMIN", "FINANCE"}, "org_roles": {"IT_ADMIN", "FINANCE"},
@@ -51,10 +64,30 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
"products": {"compliance##certifai"}, "products": {"compliance##certifai"},
}}, }},
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true}, "u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true},
}) "u-3": {ID: "u-3", Username: "multi@breakpilot.com", Enabled: true},
"u-4": {ID: "u-4", Username: "attrs-only@breakpilot.com", Enabled: true, Attributes: map[string][]string{
"tenant_id": {"acme-001"},
"tenant_slug": {"acme"},
}},
}
memberOrgs := map[string][]memberOrgRepresentation{
"u-1": {{
ID: "org-1", Alias: "acme", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}},
}},
"u-3": {
{ID: "org-1", Alias: "acme", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}}},
{ID: "org-2", Alias: "globex", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"7c3f2b10-0000-4000-8000-000000000042"}}},
{ID: "org-3", Alias: "disabled-co", Enabled: false,
Attributes: map[string][]string{"tenant_id": {"9e9e9e9e-0000-4000-8000-000000000099"}}},
},
}
srv := stubUsersKC(t, users, memberOrgs)
a := usersAdapter(srv) a := usersAdapter(srv)
t.Run("attribute projection becomes one membership", func(t *testing.T) { t.Run("org membership becomes the claim, org attrs are authoritative", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-1") got, err := a.Memberships(context.Background(), "u-1")
if err != nil { if err != nil {
t.Fatalf("memberships: %v", err) t.Fatalf("memberships: %v", err)
@@ -63,8 +96,14 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
t.Fatalf("want 1 membership, got %d", len(got)) t.Fatalf("want 1 membership, got %d", len(got))
} }
c := got[0] c := got[0]
if c.TenantID != "acme-001" || c.TenantSlug != "acme" || c.Plan != "Scale" || c.TenantStatus != "active" { // tenant identity comes from the ORG (alias + tenant_id attribute),
t.Errorf("scalar claims wrong: %+v", c) // never from the user's legacy projection attributes
if c.TenantID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" || c.TenantSlug != "acme" {
t.Errorf("org identity wrong: %+v", c)
}
// per-user claim attrs still ride along
if c.Plan != "Scale" || c.TenantStatus != "active" {
t.Errorf("user claim attrs wrong: %+v", c)
} }
if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" { if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" {
t.Errorf("org_roles wrong: %v", c.OrgRoles) t.Errorf("org_roles wrong: %v", c.OrgRoles)
@@ -74,7 +113,7 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
} }
}) })
t.Run("user without tenant attributes has zero memberships", func(t *testing.T) { t.Run("user in no org has zero memberships", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-2") got, err := a.Memberships(context.Background(), "u-2")
if err != nil { if err != nil {
t.Fatalf("memberships: %v", err) t.Fatalf("memberships: %v", err)
@@ -84,6 +123,29 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
} }
}) })
t.Run("legacy tenant attributes alone grant NO membership", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-4")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 0 {
t.Fatalf("attribute projection must not grant membership, got %+v", got)
}
})
t.Run("multiple orgs give multiple memberships, disabled org skipped", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-3")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 memberships (disabled org skipped), got %d", len(got))
}
if got[0].TenantSlug != "acme" || got[1].TenantSlug != "globex" {
t.Errorf("slugs wrong: %+v", got)
}
})
t.Run("unknown user is ErrUserNotFound", func(t *testing.T) { t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
_, err := a.Memberships(context.Background(), "nope") _, err := a.Memberships(context.Background(), "nope")
if !errors.Is(err, ErrUserNotFound) { if !errors.Is(err, ErrUserNotFound) {
@@ -92,15 +154,24 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
}) })
} }
func TestMock_Memberships(t *testing.T) { func TestHTTPAdapter_Memberships_OrgQueryFailure(t *testing.T) {
m := NewMock() mux := http.NewServeMux()
if _, err := m.Memberships(context.Background(), "ghost"); !errors.Is(err, ErrUserNotFound) { mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
t.Fatalf("want ErrUserNotFound, got %v", err) w.Header().Set("Content-Type", "application/json")
} _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "t", "expires_in": 60})
want := Claims{TenantSlug: "acme", OrgRoles: []string{"IT_ADMIN"}} })
m.Claims["u-1"] = want mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, _ *http.Request) {
got, err := m.Memberships(context.Background(), "u-1") w.Header().Set("Content-Type", "application/json")
if err != nil || len(got) != 1 || got[0].TenantSlug != "acme" { _ = json.NewEncoder(w).Encode(userRepresentation{ID: "u-1", Enabled: true})
t.Fatalf("got %+v err %v", got, err) })
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
_, err := usersAdapter(srv).Memberships(context.Background(), "u-1")
if err == nil {
t.Fatal("want error when the org query fails, got nil")
} }
} }
+106
View File
@@ -0,0 +1,106 @@
// Package product provisions a tenant into the downstream product database.
//
// The registry is the authority for tenant identity (ratified model B2), so a
// product must anchor its own tenant row to the SAME canonical UUID. Without
// this the anchors diverge: the registry knows "acme", the product database
// does not, and the product's gate rejects every request from that tenant
// because it cannot resolve the org slug.
//
// The Provisioner is a port, mirroring keycloak.Adapter: handlers depend on the
// interface, main wires the HTTP implementation when PRODUCT_API_URL is set and
// a no-op otherwise (dev convenience, and so an unconfigured deployment does
// not fail tenant creation).
package product
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// ErrUnavailable means the product API could not be reached or refused us.
var ErrUnavailable = errors.New("product: provisioning API unavailable")
// Tenant is the minimal identity a product needs to create its own row.
type Tenant struct {
ID string `json:"id"` // the registry UUID — the whole point
Name string `json:"name"`
Slug string `json:"slug"`
}
// Provisioner creates the tenant in a product's own datastore.
type Provisioner interface {
ProvisionTenant(ctx context.Context, t Tenant) error
}
// NoopProvisioner is wired when PRODUCT_API_URL is unset.
type NoopProvisioner struct{}
func (NoopProvisioner) ProvisionTenant(context.Context, Tenant) error { return nil }
// AuthHeaderFunc supplies an Authorization header per call, so a token is
// fetched lazily and refreshed rather than captured at construction. Nil means
// no header, which is the correct behaviour against a product whose gate is
// not yet enforcing.
type AuthHeaderFunc func(context.Context) (string, error)
// HTTPProvisioner calls the product's tenant-create endpoint.
type HTTPProvisioner struct {
BaseURL string
Path string // e.g. /sdk/v1/tenants
Auth AuthHeaderFunc
Client *http.Client
}
func NewHTTPProvisioner(baseURL, path string, auth AuthHeaderFunc, timeout time.Duration) *HTTPProvisioner {
if path == "" {
path = "/sdk/v1/tenants"
}
return &HTTPProvisioner{
BaseURL: baseURL, Path: path, Auth: auth,
Client: &http.Client{Timeout: timeout},
}
}
func (p *HTTPProvisioner) ProvisionTenant(ctx context.Context, t Tenant) error {
body, err := json.Marshal(t)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+p.Path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if p.Auth != nil {
h, aerr := p.Auth(ctx)
if aerr != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, aerr)
}
if h != "" {
req.Header.Set("Authorization", h)
}
}
resp, err := p.Client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode == http.StatusConflict:
// Already provisioned. The product's insert is idempotent on the
// primary key, so this is success from our point of view.
return nil
case resp.StatusCode/100 == 2:
return nil
default:
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("%w: %d %s", ErrUnavailable, resp.StatusCode, b)
}
}
+88
View File
@@ -0,0 +1,88 @@
package product
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// The whole point of this port: the product must be created with the
// REGISTRY's uuid, so both systems anchor to the same tenant identity.
func TestProvisionTenant_sendsTheRegistryUUID(t *testing.T) {
var got Tenant
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
p := NewHTTPProvisioner(srv.URL, "/sdk/v1/tenants", nil, 5*time.Second)
err := p.ProvisionTenant(context.Background(), Tenant{
ID: "2f875d6a-1d94-433a-b2ec-8529451a2d89", Name: "Acme", Slug: "acme",
})
if err != nil {
t.Fatalf("provision: %v", err)
}
if got.ID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" {
t.Errorf("registry uuid not sent: %+v", got)
}
if got.Slug != "acme" || got.Name != "Acme" {
t.Errorf("payload wrong: %+v", got)
}
}
// Re-provisioning must be safe: onboarding may retry, and the product's
// insert is idempotent on the primary key.
func TestProvisionTenant_conflictIsSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer srv.Close()
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
t.Fatalf("409 should be treated as already-provisioned, got %v", err)
}
}
func TestProvisionTenant_serverErrorIsUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"})
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("want ErrUnavailable, got %v", err)
}
}
func TestProvisionTenant_attachesAuthHeaderWhenSupplied(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("Authorization")
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
p := NewHTTPProvisioner(srv.URL, "", func(context.Context) (string, error) {
return "Bearer tok-123", nil
}, 5*time.Second)
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
t.Fatal(err)
}
if seen != "Bearer tok-123" {
t.Errorf("auth header = %q", seen)
}
}
func TestNoopProvisioner(t *testing.T) {
if err := (NoopProvisioner{}).ProvisionTenant(context.Background(), Tenant{}); err != nil {
t.Fatal(err)
}
}
+12
View File
@@ -12,6 +12,7 @@ import (
"gitea.meghsakha.com/platform/tenant-registry/internal/authn" "gitea.meghsakha.com/platform/tenant-registry/internal/authn"
"gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
"gitea.meghsakha.com/platform/tenant-registry/internal/store" "gitea.meghsakha.com/platform/tenant-registry/internal/store"
) )
@@ -22,6 +23,17 @@ type Server struct {
Store store.Store Store store.Store
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only) Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
Product product.Provisioner // never nil — main wires Noop when PRODUCT_API_URL is unset
}
// productProvisioner guarantees the "never nil" invariant the struct documents.
// Tests construct Server directly and would otherwise panic; an unset provisioner
// simply means no downstream provisioning, never a crash mid-tenant-creation.
func (s *Server) productProvisioner() product.Provisioner {
if s.Product == nil {
return product.NoopProvisioner{}
}
return s.Product
} }
// NewRouter builds the http.Handler with logging middleware applied. // NewRouter builds the http.Handler with logging middleware applied.
+22
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"time" "time"
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
"gitea.meghsakha.com/platform/tenant-registry/internal/store" "gitea.meghsakha.com/platform/tenant-registry/internal/store"
) )
@@ -93,6 +94,27 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
}) })
} }
// Best-effort product provisioning, same contract as Keycloak above: the
// registry is the authority for tenant identity, so the product gets a row
// keyed by OUR uuid. A failure must not roll the tenant back — it is
// recorded as an audit event so the divergence is traceable and fixable.
// Without this the anchors drift and the product's gate rejects every
// request from the tenant because it cannot resolve the org slug.
if perr := s.productProvisioner().ProvisionTenant(ctx, product.Tenant{
ID: t.ID, Name: t.Name, Slug: t.Slug,
}); perr != nil {
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: t.ID, Action: "product.provision_failed",
TargetID: t.ID, TargetType: "tenant",
Metadata: map[string]interface{}{"err": perr.Error()},
})
} else {
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: t.ID, Action: "product.tenant_provisioned",
TargetID: t.ID, TargetType: "tenant",
})
}
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL}) writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
} }