Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 47c6725dba fix(deps): bump kin-openapi, x/crypto, x/text, go-archive past HIGH/CRITICAL CVEs
ci / shared (pull_request) Successful in 12s
ci / test (pull_request) Successful in 21m14s
ci / image (pull_request) Skipped
Clears the 14 findings (13 HIGH, 1 CRITICAL) that have had the trivy fs
gate in the shared CI job red on every run: kin-openapi 0.138.0 -> 0.144.0
(GHSA-r277-6w6q-xmqw ValidationHandler fail-open + CVE-2026-76905),
x/crypto 0.51.0 -> 0.55.0 (ssh CVE-2026-39828..39835, 42508, 46595,
46597), x/text -> 0.41.0 (CVE-2026-56852), moby/go-archive 0.2.0 -> 0.3.0
(CVE-2026-17106). Full suite incl. postgres harness green; openapi
contract test passes against kin-openapi 0.144.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 01:01:42 +02: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
sharang 31cb06cf3d Repoint registry.breakpilot.com -> repo.breakpilot.com (Harbor) (#13)
ci / shared (push) Failing after 11s
ci / test (push) Successful in 21m10s
ci / image (push) Skipped
2026-08-09 21:22:11 +00:00
sharang 52cbfc2b82 chore(ci): repoint registry/git/cargo meghsakha.com -> breakpilot.com (#12)
ci / shared (push) Failing after 12s
ci / test (push) Successful in 21m9s
ci / image (push) Skipped
2026-08-06 09:49:53 +00:00
17 changed files with 858 additions and 90 deletions
+13 -18
View File
@@ -93,35 +93,30 @@ jobs:
run: go build ./...
image:
# Mirrors the portal CI pattern (platform/portal PR #14): push to
# registry.meghsakha.com, then POST a github-style payload signed
# with HMAC-SHA256 to the orca webhook on the master. Master matches
# on repo+branch and redeploys the breakpilot-tenant-registry service.
needs: [shared, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('Dockerfile') != ''
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: registry.meghsakha.com
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
registry.meghsakha.com/breakpilot/tenant-registry:latest
registry.meghsakha.com/breakpilot/tenant-registry:sha-${{ github.sha }}
- name: trigger orca redeploy
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:
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
run: |
BODY='{"repository":{"full_name":"platform/tenant-registry"},"ref":"refs/heads/main"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -hex | awk '{print $NF}')"
curl -ksSf -X POST \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY" \
https://46.225.100.82:6880/api/v1/webhooks/github
ORCA_TOKEN: ${{ secrets.ORCA_STAGE_TOKEN }}
+4 -4
View File
@@ -22,7 +22,7 @@ jobs:
- name: verify stage soak (>= 24h on this image)
run: |
IMG=registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
if [ "$SOAK_SECONDS" -lt 86400 ]; then
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
@@ -34,12 +34,12 @@ jobs:
- name: re-tag image as semver + env-prod
uses: docker/login-action@v3
with:
registry: registry.breakpilot.com
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- run: |
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
docker pull $IMG:env-stage
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
docker tag $IMG:env-stage $IMG:env-prod
@@ -67,7 +67,7 @@ jobs:
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
https://gitea.meghsakha.com/api/v1/repos/${{ github.repository }}/releases
https://git.breakpilot.com/api/v1/repos/${{ github.repository }}/releases
rollback-on-failure:
needs: promote
+1 -1
View File
@@ -3,7 +3,7 @@
# /tenant-registry — long-running API server
# /migrate — one-shot schema migrator (Orca init container in prod)
FROM golang:1.25-alpine AS build
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+16 -1
View File
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
@@ -59,7 +60,21 @@ func main() {
kc = keycloak.NewMock()
}
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc})
var av *authn.Verifier
if cfg.AuthEnabled {
av, err = authn.New(bootCtx, cfg.KeycloakIssuer, cfg.AuthAudience)
if err != nil {
// Fail closed: never start an "authenticated" server that
// cannot actually verify tokens.
slog.Error("AUTH_CONFIG_INCOMPLETE — AUTH_ENABLED=true but verifier init failed", "err", err)
os.Exit(1)
}
slog.Info("api auth enabled", "issuer", cfg.KeycloakIssuer, "audience", cfg.AuthAudience)
} else {
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})
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
+16 -18
View File
@@ -3,13 +3,15 @@ module gitea.meghsakha.com/platform/tenant-registry
go 1.25.0
require (
github.com/getkin/kin-openapi v0.138.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/getkin/kin-openapi v0.144.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.9.2
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 (
@@ -32,33 +34,29 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/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/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.12 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/opencontainers/go-digest v1.0.0 // 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
@@ -68,15 +66,15 @@ require (
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // 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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+36 -42
View File
@@ -18,6 +18,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -41,8 +43,10 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
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/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -50,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-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-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -77,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/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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -91,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/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
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/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/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c=
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/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
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/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
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.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
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/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
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/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/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -157,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/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
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/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -177,19 +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/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
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.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
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/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
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-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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
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=
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=
+88
View File
@@ -0,0 +1,88 @@
// Package authn validates Keycloak-issued bearer tokens for the
// tenant-registry API (RBAC rollout Phase 1; fail-closed per the ratified
// compliance auth design, model B2).
//
// The package only verifies — issuer, signature via JWKS, expiry, and
// audience. It deliberately does not authorize: tenant-registry IS the
// membership authority, so its callers are services (INTERNAL_SERVICE_ONLY
// posture) holding client_credentials tokens whose audience includes
// AUTH_EXPECTED_AUDIENCE.
package authn
import (
"context"
"errors"
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
)
// ErrNoToken means the Authorization header was absent or not a Bearer
// scheme. Handlers map it to 401 TOKEN_MISSING.
var ErrNoToken = errors.New("authorization header missing or not Bearer")
// Principal is the verified caller identity, placed in the request context
// so handlers (and, later, audit writes) can attribute actions.
type Principal struct {
Subject string // JWT sub — the Keycloak user or service-account id
ClientID string // JWT azp — which OAuth client obtained the token
Issuer string
}
type ctxKey struct{}
// WithPrincipal returns ctx carrying p.
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
return context.WithValue(ctx, ctxKey{}, p)
}
// PrincipalFrom extracts the verified principal, if any.
func PrincipalFrom(ctx context.Context) (*Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(*Principal)
return p, ok
}
// Verifier checks bearer tokens against one issuer + audience. A nil
// *Verifier means auth is disabled (AUTH_ENABLED=false) and the server
// passes requests through unauthenticated.
type Verifier struct {
issuer string
verifier *oidc.IDTokenVerifier
}
// New performs OIDC discovery against issuer and prepares JWKS-backed
// verification. Callers must treat an error as fatal when AUTH_ENABLED=true
// (AUTH_CONFIG_INCOMPLETE — refuse to start, never fall open).
func New(ctx context.Context, issuer, audience string) (*Verifier, error) {
if issuer == "" || audience == "" {
return nil, errors.New("issuer and audience are required")
}
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("oidc discovery for %s: %w", issuer, err)
}
return &Verifier{
issuer: issuer,
verifier: provider.Verifier(&oidc.Config{ClientID: audience}),
}, nil
}
// Verify checks the raw Authorization header value and returns the caller
// principal. Signature, issuer, expiry, and audience are all enforced by
// the underlying oidc verifier.
func (v *Verifier) Verify(ctx context.Context, authorization string) (*Principal, error) {
raw, ok := strings.CutPrefix(authorization, "Bearer ")
if !ok || strings.TrimSpace(raw) == "" {
return nil, ErrNoToken
}
tok, err := v.verifier.Verify(ctx, strings.TrimSpace(raw))
if err != nil {
return nil, err
}
var claims struct {
Azp string `json:"azp"`
}
_ = tok.Claims(&claims) // azp is informational; absence is not an error
return &Principal{Subject: tok.Subject, ClientID: claims.Azp, Issuer: tok.Issuer}, nil
}
+189
View File
@@ -0,0 +1,189 @@
package authn_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
jose "github.com/go-jose/go-jose/v4"
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// stubIssuer is a minimal OIDC issuer: discovery + JWKS + an RS256 signer.
type stubIssuer struct {
URL string
key *rsa.PrivateKey
sign func(t *testing.T, claims map[string]any) string
}
func newStubIssuer(t *testing.T) *stubIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
s := &stubIssuer{key: key}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": s.URL,
"jwks_uri": s.URL + "/jwks",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
{Key: key.Public(), KeyID: "test-kid", Algorithm: "RS256", Use: "sig"},
}})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
s.URL = srv.URL
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: key},
(&jose.SignerOptions{}).WithHeader("kid", "test-kid"),
)
if err != nil {
t.Fatal(err)
}
s.sign = func(t *testing.T, claims map[string]any) string {
t.Helper()
payload, _ := json.Marshal(claims)
jws, err := signer.Sign(payload)
if err != nil {
t.Fatal(err)
}
raw, err := jws.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return raw
}
return s
}
func (s *stubIssuer) claims(overrides map[string]any) map[string]any {
c := map[string]any{
"iss": s.URL,
"aud": "tenant-registry",
"sub": "svc-account-1",
"azp": "compliance-svc",
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
}
for k, v := range overrides {
c[k] = v
}
return c
}
func TestVerifier(t *testing.T) {
iss := newStubIssuer(t)
v, err := authn.New(context.Background(), iss.URL, "tenant-registry")
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
t.Run("valid token yields principal", func(t *testing.T) {
p, err := v.Verify(ctx, "Bearer "+iss.sign(t, iss.claims(nil)))
if err != nil {
t.Fatalf("verify: %v", err)
}
if p.Subject != "svc-account-1" || p.ClientID != "compliance-svc" || p.Issuer != iss.URL {
t.Errorf("principal wrong: %+v", p)
}
})
fail := func(name, header string) {
t.Run(name, func(t *testing.T) {
if _, err := v.Verify(ctx, header); err == nil {
t.Fatal("expected verification failure")
}
})
}
fail("missing header", "")
fail("not bearer", "Basic abc")
fail("garbage token", "Bearer not.a.jwt")
fail("expired", "Bearer "+iss.sign(t, iss.claims(map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})))
fail("wrong audience", "Bearer "+iss.sign(t, iss.claims(map[string]any{"aud": "someone-else"})))
fail("wrong issuer", "Bearer "+iss.sign(t, iss.claims(map[string]any{"iss": "https://evil.example"})))
t.Run("missing header is ErrNoToken", func(t *testing.T) {
if _, err := v.Verify(ctx, ""); err != authn.ErrNoToken {
t.Fatalf("want ErrNoToken, got %v", err)
}
})
}
func TestNew_failsClosed(t *testing.T) {
if _, err := authn.New(context.Background(), "", "aud"); err == nil {
t.Fatal("empty issuer must error")
}
if _, err := authn.New(context.Background(), "http://127.0.0.1:1/realms/none", "aud"); err == nil {
t.Fatal("unreachable issuer must error")
}
}
// TestRouterGating proves the wiring: health stays PUBLIC_EXPLICIT, every
// API route fails closed without a token, and a valid service token passes.
func TestRouterGating(t *testing.T) {
iss := newStubIssuer(t)
v, err := authn.New(context.Background(), iss.URL, "tenant-registry")
if err != nil {
t.Fatalf("New: %v", err)
}
handler := server.NewRouter(&server.Server{
Cfg: &config.Config{Env: "dev"},
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
Store: store.NewMemory(),
Keycloak: keycloak.NewMock(),
Auth: v,
})
srv := httptest.NewServer(handler)
defer srv.Close()
get := func(t *testing.T, path, authz string) (int, string) {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
if authz != "" {
req.Header.Set("Authorization", authz)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
if code, _ := get(t, "/healthz", ""); code != http.StatusOK {
t.Errorf("healthz must stay public, got %d", code)
}
if code, body := get(t, "/v1/catalog", ""); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_MISSING") {
t.Errorf("no token: want 401 TOKEN_MISSING, got %d %s", code, body)
}
if code, body := get(t, "/v1/catalog", "Bearer junk"); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_INVALID") {
t.Errorf("bad token: want 401 TOKEN_INVALID, got %d %s", code, body)
}
if code, _ := get(t, "/v1/catalog", "Bearer "+iss.sign(t, iss.claims(nil))); code != http.StatusOK {
t.Errorf("valid token: want 200, got %d", code)
}
}
+10
View File
@@ -20,6 +20,13 @@ type Config struct {
KeycloakClientID string
KeycloakClientSecret string
KeycloakTimeout time.Duration
// Inbound API auth (RBAC Phase 1). With AuthEnabled the server refuses
// to start unless OIDC discovery against KeycloakIssuer succeeds, and
// every non-health route requires a bearer token whose audience
// contains AuthAudience.
AuthEnabled bool
AuthAudience string
}
func Load() (*Config, error) {
@@ -39,6 +46,9 @@ func Load() (*Config, error) {
KeycloakClientID: os.Getenv("KEYCLOAK_CLIENT_ID"),
KeycloakClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"),
KeycloakTimeout: 10 * time.Second,
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
}, nil
}
+9
View File
@@ -25,6 +25,7 @@ var (
ErrOrgConflict = errors.New("keycloak: organization already exists")
ErrUserConflict = errors.New("keycloak: user already exists")
ErrUnavailable = errors.New("keycloak: unreachable")
ErrUserNotFound = errors.New("keycloak: user does not exist")
)
// InviteInput captures the per-tenant onboarding event from POST /v1/tenants.
@@ -73,6 +74,14 @@ type Adapter interface {
// flows, M14.x cancel, M12.x trial transitions).
SyncClaims(ctx context.Context, userID string, c Claims) error
// Memberships resolves the tenants user userID (the Keycloak user id,
// i.e. the JWT `sub`) belongs to, as Keycloak records them. The realm
// has no Organizations yet, so this reads the user's attribute
// projection — zero or one memberships. When the realm migrates to
// Organizations this becomes an org-membership query and callers keep
// working unchanged. Returns ErrUserNotFound for an unknown user id.
Memberships(ctx context.Context, userID string) ([]Claims, error)
// Health pings the admin endpoint. Used by readyz and the cluster cold-
// start sequence (INFRASTRUCTURE.md §10 scenario F).
Health(ctx context.Context) error
+18
View File
@@ -52,6 +52,24 @@ func (m *Mock) CreateOrgAndInvite(_ context.Context, in InviteInput) (*InviteRes
}, nil
}
// Memberships returns the last-synced Claims for userID as its single
// membership, mirroring the attribute-projection behavior of the real
// adapter. Unknown users yield ErrUserNotFound.
func (m *Mock) Memberships(_ context.Context, userID string) ([]Claims, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.FailNext != nil {
err := m.FailNext
m.FailNext = nil
return nil, err
}
c, ok := m.Claims[userID]
if !ok {
return nil, ErrUserNotFound
}
return []Claims{c}, nil
}
func (m *Mock) SyncClaims(_ context.Context, userID string, c Claims) error {
m.mu.Lock()
defer m.mu.Unlock()
+74
View File
@@ -0,0 +1,74 @@
package keycloak
import (
"context"
"fmt"
"net/http"
"strings"
)
// userRepresentation is the slice of the Admin API's UserRepresentation we
// need. Attributes arrive as map[name][]values; the KC admin console writes
// multivalued attributes either as separate list entries or as one entry
// joined with "##", so both shapes must be accepted.
type userRepresentation struct {
ID string `json:"id"`
Username string `json:"username"`
Enabled bool `json:"enabled"`
Attributes map[string][]string `json:"attributes"`
}
// Memberships implements Adapter against GET /admin/realms/{realm}/users/{id}.
func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) {
var u userRepresentation
resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
_ = resp.Body.Close()
return nil, ErrUserNotFound
}
if resp.StatusCode/100 != 2 {
_ = resp.Body.Close()
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
// projection. A user with no tenant_id and no tenant_slug attribute simply
// has no memberships — that is a valid state, not an error.
func claimsFromAttributes(attrs map[string][]string) []Claims {
c := Claims{
TenantID: attrValue(attrs, "tenant_id"),
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 == "" {
return []Claims{}
}
return []Claims{c}
}
func attrValue(attrs map[string][]string, key string) string {
if vs := attrValues(attrs, key); len(vs) > 0 {
return vs[0]
}
return ""
}
func attrValues(attrs map[string][]string, key string) []string {
out := []string{}
for _, entry := range attrs[key] {
for _, v := range strings.Split(entry, "##") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
}
return out
}
+106
View File
@@ -0,0 +1,106 @@
package keycloak
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// stubUsersKC is a users-endpoint-only KC look-alike; stubKC (client_test.go)
// covers the org/invite paths and doesn't register GET /users/{id}.
func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "test-token", "expires_in": 60})
})
mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, r *http.Request) {
u, ok := users[r.PathValue("id")]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"User not found"}`))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func usersAdapter(srv *httptest.Server) *HTTPAdapter {
return NewHTTPAdapter(HTTPConfig{
BaseURL: srv.URL, Realm: "test-realm", ClientID: "svc", ClientSecret: "secret",
})
}
func TestHTTPAdapter_Memberships(t *testing.T) {
srv := stubUsersKC(t, map[string]userRepresentation{
"u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
"tenant_id": {"acme-001"},
"tenant_slug": {"acme"},
"tenant_status": {"active"},
"plan": {"Scale"},
"org_roles": {"IT_ADMIN", "FINANCE"},
// the KC admin console writes multivalued attrs "##"-joined
"products": {"compliance##certifai"},
}},
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true},
})
a := usersAdapter(srv)
t.Run("attribute projection becomes one membership", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-1")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 membership, got %d", len(got))
}
c := got[0]
if c.TenantID != "acme-001" || c.TenantSlug != "acme" || c.Plan != "Scale" || c.TenantStatus != "active" {
t.Errorf("scalar claims wrong: %+v", c)
}
if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" {
t.Errorf("org_roles wrong: %v", c.OrgRoles)
}
if len(c.Products) != 2 || c.Products[0] != "compliance" || c.Products[1] != "certifai" {
t.Errorf("## split failed: %v", c.Products)
}
})
t.Run("user without tenant attributes has zero memberships", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-2")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 0 {
t.Fatalf("want 0 memberships, got %+v", got)
}
})
t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
_, err := a.Memberships(context.Background(), "nope")
if !errors.Is(err, ErrUserNotFound) {
t.Fatalf("want ErrUserNotFound, got %v", err)
}
})
}
func TestMock_Memberships(t *testing.T) {
m := NewMock()
if _, err := m.Memberships(context.Background(), "ghost"); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("want ErrUserNotFound, got %v", err)
}
want := Claims{TenantSlug: "acme", OrgRoles: []string{"IT_ADMIN"}}
m.Claims["u-1"] = want
got, err := m.Memberships(context.Background(), "u-1")
if err != nil || len(got) != 1 || got[0].TenantSlug != "acme" {
t.Fatalf("got %+v err %v", got, err)
}
}
+44 -5
View File
@@ -5,9 +5,11 @@
package server
import (
"errors"
"log/slog"
"net/http"
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
@@ -19,15 +21,24 @@ type Server struct {
Log *slog.Logger
Store store.Store
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)
}
// NewRouter builds the http.Handler with logging middleware applied.
//
// Route auth classes (ratified auth design): /healthz and /readyz are
// PUBLIC_EXPLICIT (orca probes, no auth by design); every other route is
// INTERNAL_SERVICE_ONLY and sits behind requireAuth. New routes land in
// the protected mux by construction — registering one on the root mux is
// the exception and needs a PUBLIC_EXPLICIT justification comment.
func NewRouter(s *Server) http.Handler {
mux := http.NewServeMux()
root := http.NewServeMux()
// health + status
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
// PUBLIC_EXPLICIT: health + status probes.
root.HandleFunc("GET /healthz", s.healthz)
root.HandleFunc("GET /readyz", s.readyz)
mux := http.NewServeMux()
// tenants
mux.HandleFunc("POST /v1/tenants", s.createTenant)
@@ -60,7 +71,35 @@ func NewRouter(s *Server) http.Handler {
// the "pull" complement for when the realm is reconfigured to fetch.
mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims)
return logRequest(s.Log)(mux)
// memberships — the B2 membership authority: which tenants does a
// JWT subject belong to, with which org_roles and entitlements.
mux.HandleFunc("GET /v1/users/{id}/memberships", s.getUserMemberships)
root.Handle("/", s.requireAuth(mux))
return logRequest(s.Log)(root)
}
// requireAuth gates the INTERNAL_SERVICE_ONLY routes. With Auth nil
// (AUTH_ENABLED=false) it passes through — the startup log carries the
// warning, and the Auth-5 activation flips the env, not the code.
func (s *Server) requireAuth(next http.Handler) http.Handler {
if s.Auth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.Auth.Verify(r.Context(), r.Header.Get("Authorization"))
if err != nil {
if errors.Is(err, authn.ErrNoToken) {
writeError(w, http.StatusUnauthorized, "TOKEN_MISSING", "bearer token required")
return
}
// Signature, issuer, expiry, and audience failures all land
// here; the message says which without echoing the token.
writeError(w, http.StatusUnauthorized, "TOKEN_INVALID", err.Error())
return
}
next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
})
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// membershipItem is one tenant a user belongs to. Source records which
// system produced the authoritative half: "registry" when the tenant exists
// here (status/plan/products come from our tables), "keycloak" when only
// the attribute projection knows it (e.g. a tenant seeded directly in the
// realm that the registry has not onboarded yet).
type membershipItem struct {
keycloak.Claims
Source string `json:"source"`
}
type membershipsResp struct {
UserID string `json:"user_id"`
Memberships []membershipItem `json:"memberships"`
}
// getUserMemberships is GET /v1/users/{id}/memberships — the membership
// authority endpoint (ratified auth design, model B2). Product backends
// call it to answer "which tenants does this JWT subject belong to, with
// which roles", instead of trusting token claims or client headers.
//
// Resolution: Keycloak (via the Adapter) supplies user→tenant links and
// org_roles; where the tenant is registered here, status, plan, and
// product entitlements are overridden from the registry tables, which are
// authoritative for lifecycle and billing state.
func (s *Server) getUserMemberships(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if userID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "user id required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
claims, err := s.Keycloak.Memberships(ctx, userID)
if err != nil {
switch {
case errors.Is(err, keycloak.ErrUserNotFound):
writeError(w, http.StatusNotFound, "not_found", "user does not exist")
case errors.Is(err, keycloak.ErrUnavailable), errors.Is(err, keycloak.ErrUnauthorized):
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal", err.Error())
}
return
}
items := make([]membershipItem, 0, len(claims))
for _, c := range claims {
items = append(items, s.enrichMembership(ctx, c))
}
writeJSON(w, http.StatusOK, membershipsResp{UserID: userID, Memberships: items})
}
// enrichMembership overrides the Keycloak attribute projection with
// registry truth when the tenant is known here. Lookup prefers the slug —
// the attribute tenant_id predates the registry for hand-seeded dev users
// and may not be a registry id.
func (s *Server) enrichMembership(ctx context.Context, c keycloak.Claims) membershipItem {
var (
t *store.Tenant
err error
)
if c.TenantSlug != "" {
t, err = s.Store.GetTenantBySlug(ctx, c.TenantSlug)
} else {
t, err = s.Store.GetTenant(ctx, c.TenantID)
}
if err != nil || t == nil {
return membershipItem{Claims: c, Source: "keycloak"}
}
products := []string{}
if tps, perr := s.Store.ListTenantProducts(ctx, t.ID); perr == nil {
for _, p := range tps {
if p.Enabled {
products = append(products, p.Product)
}
}
}
return membershipItem{
Claims: keycloak.Claims{
TenantID: t.ID,
TenantSlug: t.Slug,
OrgRoles: c.OrgRoles, // roles stay Keycloak-owned
Products: products,
Plan: t.Plan,
TenantStatus: t.Status,
},
Source: "registry",
}
}
+74
View File
@@ -0,0 +1,74 @@
package server_test
import (
"net/http"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
)
type membershipsBody struct {
UserID string `json:"user_id"`
Memberships []struct {
keycloak.Claims
Source string `json:"source"`
} `json:"memberships"`
}
func TestGetUserMemberships(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
// The KC attribute projection carries a stale plan/status and a
// legacy tenant_id — the registry row must win (source: registry).
h.kcMock.Claims["u-1"] = keycloak.Claims{
TenantID: "kc-legacy-id", TenantSlug: "acme",
OrgRoles: []string{"IT_ADMIN"}, Plan: "stale-plan", TenantStatus: "stale",
}
resp, raw := h.do(http.MethodGet, "/v1/users/u-1/memberships", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d: %s", resp.StatusCode, raw)
}
body := decode[membershipsBody](t, raw)
if body.UserID != "u-1" || len(body.Memberships) != 1 {
t.Fatalf("unexpected body: %s", raw)
}
m := body.Memberships[0]
if m.Source != "registry" {
t.Errorf("want source registry, got %q", m.Source)
}
if m.TenantID != h.tenant.ID || m.TenantSlug != "acme" {
t.Errorf("registry identity not authoritative: %+v", m.Claims)
}
if m.Plan != h.tenant.Plan || m.TenantStatus != h.tenant.Status {
t.Errorf("registry lifecycle not authoritative: plan=%q status=%q", m.Plan, m.TenantStatus)
}
if len(m.OrgRoles) != 1 || m.OrgRoles[0] != "IT_ADMIN" {
t.Errorf("org_roles must stay keycloak-owned: %v", m.OrgRoles)
}
})
}
func TestGetUserMemberships_unknownTenantFallsBackToKeycloak(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
h.kcMock.Claims["u-2"] = keycloak.Claims{
TenantID: "ghost-001", TenantSlug: "ghost",
OrgRoles: []string{"USER"}, Plan: "Scale", TenantStatus: "active",
}
resp, raw := h.do(http.MethodGet, "/v1/users/u-2/memberships", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d: %s", resp.StatusCode, raw)
}
m := decode[membershipsBody](t, raw).Memberships[0]
if m.Source != "keycloak" || m.TenantSlug != "ghost" || m.Plan != "Scale" {
t.Errorf("expected untouched keycloak projection, got %+v (source %q)", m.Claims, m.Source)
}
})
}
func TestGetUserMemberships_unknownUser404(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do(http.MethodGet, "/v1/users/nobody/memberships", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("want 404, got %d", resp.StatusCode)
}
})
}
+56 -1
View File
@@ -8,7 +8,10 @@ info:
`PLATFORM_ARCHITECTURE.md §5c` for the schema, and
`PRODUCT_INTEGRATION_SPEC.md §8.4` for the audit shape.
This API is not yet authenticated — M4.3 adds Keycloak JWT validation.
Auth (RBAC Phase 1): with AUTH_ENABLED=true every route except
/healthz and /readyz requires a Keycloak-issued bearer token whose
audience contains AUTH_EXPECTED_AUDIENCE (INTERNAL_SERVICE_ONLY
posture). With AUTH_ENABLED=false (dev default) the API is open.
contact:
email: oncall@breakpilot.com
license:
@@ -250,6 +253,49 @@ paths:
description: Revoked.
"404": { $ref: "#/components/responses/NotFound" }
/v1/users/{id}/memberships:
get:
summary: Resolve which tenants a user belongs to (membership authority).
description: |
The B2 membership-authority endpoint (ratified compliance auth
design). Product backends call this with the JWT `sub` instead of
trusting token claims or client-supplied headers. Keycloak supplies
the user→tenant links and org_roles; where the tenant is registered
here, tenant_status / plan / products are overridden from registry
tables (source: "registry"), otherwise the Keycloak attribute
projection is returned as-is (source: "keycloak").
parameters:
- name: id
in: path
required: true
description: Keycloak user id (the JWT `sub`).
schema: { type: string }
responses:
"200":
description: Memberships (possibly empty) for the user.
content:
application/json:
schema:
type: object
required: [user_id, memberships]
properties:
user_id: { type: string }
memberships:
type: array
items:
allOf:
- $ref: "#/components/schemas/Claims"
- type: object
required: [source]
properties:
source: { type: string, enum: [registry, keycloak] }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
"503":
description: Keycloak unreachable — membership cannot be resolved.
content:
application/json: { schema: { $ref: "#/components/schemas/Error" } }
/v1/internal/keycloak/claims:
post:
summary: Resolve the up-to-date claim bundle for a user/tenant.
@@ -356,6 +402,15 @@ paths:
"400": { $ref: "#/components/responses/BadRequest" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
Keycloak-issued token (client_credentials for services). Enforced
on all non-health routes when AUTH_ENABLED=true.
responses:
BadRequest:
description: Input failed validation.