Compare commits
6
Commits
d4e8042b94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce75ed04c2 | ||
|
|
31cb06cf3d | ||
|
|
52cbfc2b82 | ||
|
|
8fa1a1bffd | ||
|
|
a37ae1d121 | ||
|
|
9138731eea |
@@ -9,3 +9,11 @@ KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev
|
|||||||
# only; data lost on restart). Set to use the dev-stack Postgres + run
|
# only; data lost on restart). Set to use the dev-stack Postgres + run
|
||||||
# `make migrate-up` first.
|
# `make migrate-up` first.
|
||||||
# DATABASE_URL=postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable
|
# DATABASE_URL=postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable
|
||||||
|
|
||||||
|
# Keycloak Admin API — when these are set, tenant-registry calls the real KC
|
||||||
|
# Admin API to provision orgs + invite IT_ADMINs on POST /v1/tenants. Leave
|
||||||
|
# empty to use the in-process Mock adapter (no real KC writes).
|
||||||
|
# KEYCLOAK_ADMIN_URL=http://localhost:8080
|
||||||
|
# KEYCLOAK_REALM=breakpilot-dev
|
||||||
|
# KEYCLOAK_CLIENT_ID=tenant-registry-admin
|
||||||
|
# KEYCLOAK_CLIENT_SECRET=...from infisical...
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
# own test binary — and including it triggers a covdata-tool error
|
# own test binary — and including it triggers a covdata-tool error
|
||||||
# on packages with no _test.go files. -coverpkg makes the server's
|
# on packages with no _test.go files. -coverpkg makes the server's
|
||||||
# exercise of store/* count toward coverage.
|
# exercise of store/* count toward coverage.
|
||||||
run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/...
|
run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/... ./internal/keycloak/...
|
||||||
|
|
||||||
- name: coverage gate
|
- name: coverage gate
|
||||||
run: |
|
run: |
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.breakpilot.com
|
registry: repo.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
@@ -109,12 +109,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
||||||
registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
|
||||||
|
|
||||||
- uses: anchore/sbom-action@v0
|
- uses: anchore/sbom-action@v0
|
||||||
with:
|
with:
|
||||||
image: registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
image: repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
||||||
|
|
||||||
- name: orca deploy stage
|
- name: orca deploy stage
|
||||||
run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
|
run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: verify stage soak (>= 24h on this image)
|
- name: verify stage soak (>= 24h on this image)
|
||||||
run: |
|
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)
|
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
||||||
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
||||||
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
- name: re-tag image as semver + env-prod
|
- name: re-tag image as semver + env-prod
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.breakpilot.com
|
registry: repo.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
|
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
|
||||||
docker pull $IMG:env-stage
|
docker pull $IMG:env-stage
|
||||||
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
||||||
docker tag $IMG:env-stage $IMG:env-prod
|
docker tag $IMG:env-stage $IMG:env-prod
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-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)" \
|
-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:
|
rollback-on-failure:
|
||||||
needs: promote
|
needs: promote
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- feat(store): CreateTenant defaults trial_ends_at to NOW()+14d for customer kind; demo kind gets status='demo' and no trial end
|
||||||
|
- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant.
|
||||||
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
||||||
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
||||||
- feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant
|
- feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ make build # compile to ./bin/tenant-registry
|
|||||||
|
|
||||||
Env vars (override at the shell):
|
Env vars (override at the shell):
|
||||||
|
|
||||||
| Var | Default | Purpose |
|
| Var | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` |
|
| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` |
|
||||||
| `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) |
|
| `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) |
|
||||||
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL |
|
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL (the JWT signer) |
|
||||||
| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR |
|
| `DATABASE_URL` | empty (in-memory store fallback) | Postgres DSN; service uses Memory when empty |
|
||||||
|
| `KEYCLOAK_ADMIN_URL` | empty (Mock adapter used in dev) | KC base URL for the Admin API |
|
||||||
|
| `KEYCLOAK_REALM` | `breakpilot-dev` | Realm name for Admin API calls |
|
||||||
|
| `KEYCLOAK_CLIENT_ID` | empty | Service-account client id (Admin) |
|
||||||
|
| `KEYCLOAK_CLIENT_SECRET` | empty | Service-account client secret |
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@@ -76,6 +80,37 @@ The service picks its store based on `DATABASE_URL`:
|
|||||||
|
|
||||||
Both implementations pass the same test harness (`internal/server/server_test.go` → `eachStore`).
|
Both implementations pass the same test harness (`internal/server/server_test.go` → `eachStore`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Keycloak adapter (M4.3)
|
||||||
|
|
||||||
|
`internal/keycloak` is the seam between tenant-registry and Keycloak. The
|
||||||
|
`Adapter` interface has two implementations:
|
||||||
|
|
||||||
|
| Implementation | When used |
|
||||||
|
|---|---|
|
||||||
|
| `Mock` | Default in dev when `KEYCLOAK_ADMIN_URL` is empty |
|
||||||
|
| `HTTPAdapter` | Real KC Admin API client; activated when KC env vars are populated |
|
||||||
|
|
||||||
|
`POST /v1/tenants` now accepts `admin_email` and `admin_name`. When set, the
|
||||||
|
adapter creates a Keycloak organization (alias = the tenant slug), invites
|
||||||
|
the user as the IT_ADMIN, and triggers the verify-email + set-password
|
||||||
|
flow. The response body includes `invite_url` so dev testers can use it
|
||||||
|
without waiting for the email — production discards it.
|
||||||
|
|
||||||
|
**KC failures are non-fatal.** The tenant row still lands; a
|
||||||
|
`keycloak.provision_failed` audit event captures the error so the operator
|
||||||
|
can resend the invite from the KC UI.
|
||||||
|
|
||||||
|
`POST /v1/internal/keycloak/claims` resolves a tenant's current entitlement
|
||||||
|
bundle (tenant_id, slug, products, plan, status). The realm's protocol
|
||||||
|
mapper calls this at token-issuance time (or whenever user attributes
|
||||||
|
need a refresh).
|
||||||
|
|
||||||
|
For production, provision a service-account client in the realm with the
|
||||||
|
`realm-management:manage-users` + `manage-organizations` roles. Drop its
|
||||||
|
credentials in Infisical at `/{env}/tenant-registry/KEYCLOAK_CLIENT_*`.
|
||||||
|
|
||||||
## Schema migrations (M4.1)
|
## Schema migrations (M4.1)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -131,3 +166,4 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md). TL;DR: branch from main, open a PR,
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
|
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -10,7 +10,9 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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/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"
|
||||||
)
|
)
|
||||||
@@ -42,7 +44,37 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s})
|
var kc keycloak.Adapter
|
||||||
|
if cfg.KeycloakAdminURL != "" && cfg.KeycloakClientID != "" {
|
||||||
|
kc = keycloak.NewHTTPAdapter(keycloak.HTTPConfig{
|
||||||
|
BaseURL: cfg.KeycloakAdminURL,
|
||||||
|
Realm: cfg.KeycloakRealm,
|
||||||
|
ClientID: cfg.KeycloakClientID,
|
||||||
|
ClientSecret: cfg.KeycloakClientSecret,
|
||||||
|
Timeout: cfg.KeycloakTimeout,
|
||||||
|
})
|
||||||
|
slog.Info("keycloak adapter configured",
|
||||||
|
"url", cfg.KeycloakAdminURL, "realm", cfg.KeycloakRealm, "client_id", cfg.KeycloakClientID)
|
||||||
|
} else {
|
||||||
|
slog.Warn("KEYCLOAK_ADMIN_URL not set — using mock adapter (dev only; no real KC writes)")
|
||||||
|
kc = keycloak.NewMock()
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ module gitea.meghsakha.com/platform/tenant-registry
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
github.com/getkin/kin-openapi v0.138.0
|
github.com/getkin/kin-openapi v0.138.0
|
||||||
|
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
|
||||||
@@ -75,6 +77,7 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
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/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
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=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
@@ -43,6 +45,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
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.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
|
||||||
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
||||||
|
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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -179,6 +183,8 @@ go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa
|
|||||||
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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
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/sync v0.20.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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -10,6 +11,22 @@ type Config struct {
|
|||||||
Addr string // listen address, e.g. ":8090"
|
Addr string // listen address, e.g. ":8090"
|
||||||
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
|
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
|
||||||
DatabaseURL string // postgres DSN (unused in skeleton; in-memory store)
|
DatabaseURL string // postgres DSN (unused in skeleton; in-memory store)
|
||||||
|
|
||||||
|
// Keycloak Admin API — only used if KeycloakAdminURL is set. Empty
|
||||||
|
// values disable the adapter and tenant-registry falls back to the
|
||||||
|
// Mock (dev convenience).
|
||||||
|
KeycloakAdminURL string
|
||||||
|
KeycloakRealm string
|
||||||
|
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) {
|
func Load() (*Config, error) {
|
||||||
@@ -23,6 +40,15 @@ func Load() (*Config, error) {
|
|||||||
Addr: getenv("ADDR", ":8090"),
|
Addr: getenv("ADDR", ":8090"),
|
||||||
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
|
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
|
||||||
|
KeycloakAdminURL: os.Getenv("KEYCLOAK_ADMIN_URL"),
|
||||||
|
KeycloakRealm: getenv("KEYCLOAK_REALM", "breakpilot-dev"),
|
||||||
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Package keycloak adapts the Keycloak Admin API to the tenant-registry's
|
||||||
|
// language of "tenants" and "IT_ADMIN invites".
|
||||||
|
//
|
||||||
|
// The Adapter interface is the seam: tenant-registry handlers depend on
|
||||||
|
// it, never on the concrete HTTP client. Tests use Mock; production uses
|
||||||
|
// HTTPAdapter against the real KC at the configured base URL.
|
||||||
|
//
|
||||||
|
// Required Keycloak features (verified against KC 26):
|
||||||
|
// - Organizations feature enabled in the realm (organizationsEnabled: true)
|
||||||
|
// - Realm roles: BREAKPILOT_ADMIN, SUPPORT_ENGINEER, SALES_REP
|
||||||
|
// - Group `/IT_ADMIN` (used as the org_role marker for invited users)
|
||||||
|
//
|
||||||
|
// All errors are wrapped with %w so callers can errors.Is them against
|
||||||
|
// ErrUnauthorized / ErrOrgConflict / ErrUserConflict.
|
||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors.
|
||||||
|
var (
|
||||||
|
ErrUnauthorized = errors.New("keycloak: admin auth failed")
|
||||||
|
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.
|
||||||
|
// The adapter creates a Keycloak organization, invites the IT_ADMIN, and
|
||||||
|
// stores the (TenantID, OrganizationID) link back in the caller's Tenant.
|
||||||
|
type InviteInput struct {
|
||||||
|
TenantID string // the tenant_registry id; stored as KC org attribute "tenant_id"
|
||||||
|
Slug string // becomes the KC org alias
|
||||||
|
Name string // human-readable org name
|
||||||
|
AdminEmail string // IT_ADMIN to invite (required)
|
||||||
|
AdminName string // optional display name
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteResult is what the adapter produces. OrganizationID is what the
|
||||||
|
// tenant-registry stores so it can later assert tenants.id ↔ kc.org_id 1:1.
|
||||||
|
type InviteResult struct {
|
||||||
|
OrganizationID string
|
||||||
|
UserID string
|
||||||
|
// InviteURL is what the user clicks to set their password. In dev (no
|
||||||
|
// Stalwart yet) we surface it in the response so testers can use it
|
||||||
|
// directly. In prod it's emailed by Keycloak and we discard it.
|
||||||
|
InviteURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims is the tenant-scoped claim bundle the protocol-mapper would push
|
||||||
|
// into a JWT at token issuance. Returned by Adapter.ClaimsFor so the user-
|
||||||
|
// attributes can be refreshed on subscription change.
|
||||||
|
type Claims struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
TenantSlug string `json:"tenant_slug"`
|
||||||
|
OrgRoles []string `json:"org_roles"`
|
||||||
|
Products []string `json:"products"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
TenantStatus string `json:"tenant_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter is the shape tenant-registry handlers code against. HTTPAdapter
|
||||||
|
// is the real one; Mock satisfies the same surface for tests.
|
||||||
|
type Adapter interface {
|
||||||
|
// CreateOrgAndInvite is the M4.3 happy path. Atomic from the caller's
|
||||||
|
// PoV: either both org+user land or neither does.
|
||||||
|
CreateOrgAndInvite(ctx context.Context, in InviteInput) (*InviteResult, error)
|
||||||
|
|
||||||
|
// SyncClaims pushes the current Claims into the user's Keycloak
|
||||||
|
// attributes. Called whenever entitlements change (M4.2 catalog/trial
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPAdapter implements Adapter against the real Keycloak Admin REST API.
|
||||||
|
// Uses client-credentials grant; an admin-role'd service account on the
|
||||||
|
// realm should be configured. Token is cached and refreshed before expiry.
|
||||||
|
type HTTPAdapter struct {
|
||||||
|
cfg HTTPConfig
|
||||||
|
hc *http.Client
|
||||||
|
|
||||||
|
// token cache
|
||||||
|
mu sync.Mutex
|
||||||
|
tokenStr string
|
||||||
|
tokenExp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPConfig — every value read from env via internal/config.
|
||||||
|
type HTTPConfig struct {
|
||||||
|
BaseURL string // e.g. http://localhost:8080
|
||||||
|
Realm string // breakpilot-dev | breakpilot-prod
|
||||||
|
ClientID string // service account client id
|
||||||
|
ClientSecret string // service account client secret
|
||||||
|
AdminEmail string // platform admin email — used to gate the BREAKPILOT_ADMIN realm role check
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPAdapter(cfg HTTPConfig) *HTTPAdapter {
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &HTTPAdapter{cfg: cfg, hc: &http.Client{Timeout: cfg.Timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── auth ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) token(ctx context.Context) (string, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.tokenStr != "" && time.Now().Before(a.tokenExp.Add(-30*time.Second)) {
|
||||||
|
return a.tokenStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"client_id": {a.cfg.ClientID},
|
||||||
|
"client_secret": {a.cfg.ClientSecret},
|
||||||
|
}
|
||||||
|
tokenURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", a.cfg.BaseURL, a.cfg.Realm)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := a.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("keycloak token: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var tr struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||||
|
return "", fmt.Errorf("keycloak token decode: %w", err)
|
||||||
|
}
|
||||||
|
a.tokenStr = tr.AccessToken
|
||||||
|
a.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
||||||
|
return a.tokenStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminCall is the common request shape against /admin/realms/{realm}/...
|
||||||
|
// On 401/403 it clears the token and tries once more.
|
||||||
|
func (a *HTTPAdapter) adminCall(ctx context.Context, method, path string, body any, into any) (resp *http.Response, err error) {
|
||||||
|
tok, err := a.token(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err = a.doAdmin(ctx, method, path, body, tok)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
a.mu.Lock()
|
||||||
|
a.tokenStr = "" // force refresh
|
||||||
|
a.mu.Unlock()
|
||||||
|
if tok, err = a.token(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err = a.doAdmin(ctx, method, path, body, tok)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if into != nil && resp.StatusCode/100 == 2 && resp.ContentLength != 0 {
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(into); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) doAdmin(ctx context.Context, method, path string, body any, tok string) (*http.Response, error) {
|
||||||
|
u := fmt.Sprintf("%s/admin/realms/%s%s", a.cfg.BaseURL, a.cfg.Realm, path)
|
||||||
|
var bodyR io.Reader
|
||||||
|
if body != nil {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bodyR = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, bodyR)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := a.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health pings /admin/serverinfo (cheap, returns 200 on a working install).
|
||||||
|
func (a *HTTPAdapter) Health(ctx context.Context) error {
|
||||||
|
tok, err := a.token(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf("%s/admin/serverinfo", a.cfg.BaseURL)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
resp, err := a.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return fmt.Errorf("keycloak health: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubKC builds a minimal KC look-alike: token endpoint + the Admin API
|
||||||
|
// paths the HTTPAdapter actually calls. Each path is a single handler that
|
||||||
|
// asserts the request shape and returns the bare-minimum valid response.
|
||||||
|
type stubKC struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
tokenCalls atomic.Int32
|
||||||
|
orgCalls atomic.Int32
|
||||||
|
userCalls atomic.Int32
|
||||||
|
memberCalls atomic.Int32
|
||||||
|
emailCalls atomic.Int32
|
||||||
|
healthCalls atomic.Int32
|
||||||
|
syncCalls atomic.Int32
|
||||||
|
|
||||||
|
tokenFails atomic.Bool // when true, /token returns 401 once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubKC(t *testing.T) *stubKC {
|
||||||
|
t.Helper()
|
||||||
|
s := &stubKC{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.tokenCalls.Add(1)
|
||||||
|
if s.tokenFails.Swap(false) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "test-token", "expires_in": 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/serverinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.healthCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"systemInfo":{"version":"26.0.0"}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.orgCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations/org-xyz/members", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.memberCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.userCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/users/user-abc")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users/user-abc/execute-actions-email", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.emailCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users/user-abc", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.syncCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.srv = httptest.NewServer(mux)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubKC) close() { s.srv.Close() }
|
||||||
|
|
||||||
|
func newTestAdapter(srv *httptest.Server) *HTTPAdapter {
|
||||||
|
return NewHTTPAdapter(HTTPConfig{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Realm: "test-realm",
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_health(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
if err := a.Health(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.healthCalls.Load() != 1 {
|
||||||
|
t.Errorf("health calls = %d", s.healthCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
res, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "acme", Name: "Acme Inc.",
|
||||||
|
AdminEmail: "owner@acme.test", AdminName: "Alice Owner",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.OrganizationID != "org-xyz" || res.UserID != "user-abc" {
|
||||||
|
t.Errorf("unexpected ids: %+v", res)
|
||||||
|
}
|
||||||
|
if s.orgCalls.Load() != 1 || s.userCalls.Load() != 1 ||
|
||||||
|
s.memberCalls.Load() != 1 || s.emailCalls.Load() != 1 {
|
||||||
|
t.Errorf("call counts: org=%d user=%d member=%d email=%d",
|
||||||
|
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "x", Name: "X",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty admin email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_orgConflict(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", tokenOK)
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
a := newTestAdapter(srv)
|
||||||
|
|
||||||
|
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "x", Name: "X", AdminEmail: "a@b.test",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrOrgConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrOrgConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenUnavailable(t *testing.T) {
|
||||||
|
// No KC server at all — adapter should surface ErrUnavailable.
|
||||||
|
a := NewHTTPAdapter(HTTPConfig{
|
||||||
|
BaseURL: "http://127.0.0.1:1", Realm: "test", ClientID: "x", ClientSecret: "y", Timeout: 1 * time.Second,
|
||||||
|
})
|
||||||
|
err := a.Health(context.Background())
|
||||||
|
if !errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Errorf("err = %v, want ErrUnavailable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenUnauthorized(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
a := newTestAdapter(srv)
|
||||||
|
err := a.Health(context.Background())
|
||||||
|
if !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Errorf("err = %v, want ErrUnauthorized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_syncClaims(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
err := a.SyncClaims(context.Background(), "user-abc", Claims{
|
||||||
|
TenantID: "t1", TenantSlug: "acme", Plan: "professional",
|
||||||
|
Products: []string{"certifai"}, TenantStatus: "active",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.syncCalls.Load() != 1 {
|
||||||
|
t.Errorf("sync calls = %d", s.syncCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenIsCached(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
// Three Health calls should produce ONE token fetch (cached).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := a.Health(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.tokenCalls.Load() != 1 {
|
||||||
|
t.Errorf("token fetches = %d, want 1 (cache miss)", s.tokenCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenOK is a reusable handler that always returns a working token.
|
||||||
|
func tokenOK(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := make([]byte, r.ContentLength)
|
||||||
|
_, _ = r.Body.Read(b)
|
||||||
|
if !strings.Contains(string(b), "client_credentials") {
|
||||||
|
http.Error(w, "grant_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"t","expires_in":60}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock is the test-friendly Adapter. Records every call; predictable IDs.
|
||||||
|
// Use in unit tests + as the default adapter when KEYCLOAK_BASE_URL is empty
|
||||||
|
// (dev convenience).
|
||||||
|
type Mock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Orgs map[string]string // tenantID → orgID
|
||||||
|
Users map[string]string // email → userID
|
||||||
|
Claims map[string]Claims // userID → last synced
|
||||||
|
FailNext error // set to force the next call to fail
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMock() *Mock {
|
||||||
|
return &Mock{
|
||||||
|
Orgs: map[string]string{},
|
||||||
|
Users: map[string]string{},
|
||||||
|
Claims: map[string]Claims{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mock) Health(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (m *Mock) CreateOrgAndInvite(_ context.Context, in InviteInput) (*InviteResult, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.FailNext != nil {
|
||||||
|
err := m.FailNext
|
||||||
|
m.FailNext = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, taken := m.Orgs[in.TenantID]; taken {
|
||||||
|
return nil, ErrOrgConflict
|
||||||
|
}
|
||||||
|
if _, taken := m.Users[in.AdminEmail]; taken {
|
||||||
|
return nil, ErrUserConflict
|
||||||
|
}
|
||||||
|
orgID := "mock-org-" + in.Slug
|
||||||
|
userID := "mock-user-" + in.AdminEmail
|
||||||
|
m.Orgs[in.TenantID] = orgID
|
||||||
|
m.Users[in.AdminEmail] = userID
|
||||||
|
return &InviteResult{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
UserID: userID,
|
||||||
|
InviteURL: "http://mock-keycloak/invite/" + userID,
|
||||||
|
}, 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()
|
||||||
|
if m.FailNext != nil {
|
||||||
|
err := m.FailNext
|
||||||
|
m.FailNext = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
return errors.New("mock: user_id required")
|
||||||
|
}
|
||||||
|
m.Claims[userID] = c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMock_createOrgAndInvite(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
res, err := m.CreateOrgAndInvite(ctx, InviteInput{
|
||||||
|
TenantID: "t1", Slug: "acme", Name: "Acme",
|
||||||
|
AdminEmail: "a@acme.test", AdminName: "Alice",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.OrganizationID == "" || res.UserID == "" {
|
||||||
|
t.Errorf("ids missing: %+v", res)
|
||||||
|
}
|
||||||
|
if m.Orgs["t1"] != res.OrganizationID {
|
||||||
|
t.Errorf("Orgs map not updated")
|
||||||
|
}
|
||||||
|
if m.Users["a@acme.test"] != res.UserID {
|
||||||
|
t.Errorf("Users map not updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMock_orgConflict(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"})
|
||||||
|
_, err := m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "b@y.test"})
|
||||||
|
if !errors.Is(err, ErrOrgConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrOrgConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMock_userConflict(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"})
|
||||||
|
_, err := m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t2", Slug: "z", AdminEmail: "a@y.test"})
|
||||||
|
if !errors.Is(err, ErrUserConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrUserConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMock_failNextHook(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
m.FailNext = ErrUnavailable
|
||||||
|
_, err := m.CreateOrgAndInvite(context.Background(), InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"})
|
||||||
|
if !errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Errorf("err = %v, want ErrUnavailable", err)
|
||||||
|
}
|
||||||
|
// Subsequent call recovers
|
||||||
|
_, err = m.CreateOrgAndInvite(context.Background(), InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("FailNext should clear after one use; err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMock_syncClaims(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
err := m.SyncClaims(context.Background(), "user-1", Claims{
|
||||||
|
TenantID: "t1", Plan: "professional", Products: []string{"certifai"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Claims["user-1"].Plan != "professional" {
|
||||||
|
t.Errorf("claims not stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMock_syncClaimsRequiresUserID(t *testing.T) {
|
||||||
|
m := NewMock()
|
||||||
|
err := m.SyncClaims(context.Background(), "", Claims{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty user id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── organizations API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type orgCreate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Domains []map[string]any `json:"domains,omitempty"`
|
||||||
|
Attributes map[string][]string `json:"attributes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userCreate struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName string `json:"firstName,omitempty"`
|
||||||
|
LastName string `json:"lastName,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
|
Attributes map[string][]string `json:"attributes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgAndInvite creates the organization, creates the IT_ADMIN user,
|
||||||
|
// adds them as org member, and triggers the verify-email-and-set-password
|
||||||
|
// flow (Keycloak's native "invite via email" path).
|
||||||
|
//
|
||||||
|
// Best-effort atomicity: on partial failure we leave KC in whatever state
|
||||||
|
// it's in and surface the error. A follow-up reconciler (M4.x or M14.x)
|
||||||
|
// can heal divergence. For local dev where everything either succeeds or
|
||||||
|
// the test surfaces the exact failure, this is fine.
|
||||||
|
func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (*InviteResult, error) {
|
||||||
|
if in.AdminEmail == "" {
|
||||||
|
return nil, fmt.Errorf("keycloak: admin email required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create org with tenant_id baked in as an attribute so we can
|
||||||
|
// correlate the two systems with a single Admin API call later.
|
||||||
|
orgPayload := orgCreate{
|
||||||
|
Name: in.Name,
|
||||||
|
Alias: in.Slug,
|
||||||
|
Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID),
|
||||||
|
Attributes: map[string][]string{
|
||||||
|
"tenant_id": {in.TenantID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := a.adminCall(ctx, http.MethodPost, "/organizations", orgPayload, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusCreated:
|
||||||
|
// keep going
|
||||||
|
case http.StatusConflict:
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("%w: alias=%s", ErrOrgConflict, in.Slug)
|
||||||
|
default:
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("create org: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
// Keycloak returns the id in the Location header.
|
||||||
|
orgID := lastSegment(resp.Header.Get("Location"))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if orgID == "" {
|
||||||
|
// Fallback: query by alias.
|
||||||
|
orgID, err = a.findOrgByAlias(ctx, in.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create org: missing Location and lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the user (disabled until they set a password).
|
||||||
|
first, last := splitName(in.AdminName)
|
||||||
|
userPayload := userCreate{
|
||||||
|
Username: in.AdminEmail,
|
||||||
|
Email: in.AdminEmail,
|
||||||
|
FirstName: first,
|
||||||
|
LastName: last,
|
||||||
|
Enabled: true,
|
||||||
|
EmailVerified: false,
|
||||||
|
Attributes: map[string][]string{
|
||||||
|
"tenant_id": {in.TenantID},
|
||||||
|
"tenant_slug": {in.Slug},
|
||||||
|
"org_roles": {"IT_ADMIN"},
|
||||||
|
"tenant_status": {"trial"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err = a.adminCall(ctx, http.MethodPost, "/users", userPayload, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusCreated:
|
||||||
|
// keep going
|
||||||
|
case http.StatusConflict:
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("%w: email=%s", ErrUserConflict, in.AdminEmail)
|
||||||
|
default:
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("create user: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
userID := lastSegment(resp.Header.Get("Location"))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if userID == "" {
|
||||||
|
userID, err = a.findUserByEmail(ctx, in.AdminEmail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create user: missing Location and lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add user to organization (member).
|
||||||
|
addBody := map[string]string{"id": userID}
|
||||||
|
resp, err = a.adminCall(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/organizations/%s/members", orgID), addBody, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("add member: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("add member: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// 4. Trigger the verify-email + set-password execute-actions email.
|
||||||
|
// In dev (no Stalwart) we also surface the action-token URL to
|
||||||
|
// the caller so they can hit it directly.
|
||||||
|
inviteURL, err := a.executeActionsEmail(ctx, userID,
|
||||||
|
[]string{"VERIFY_EMAIL", "UPDATE_PASSWORD"},
|
||||||
|
"https://breakpilot.com/onboard")
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal — admin can resend from the KC UI.
|
||||||
|
return &InviteResult{OrganizationID: orgID, UserID: userID, InviteURL: ""}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &InviteResult{OrganizationID: orgID, UserID: userID, InviteURL: inviteURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncClaims pushes the up-to-date claim bundle into the user's KC
|
||||||
|
// attributes. Called by tenant-registry whenever entitlements change.
|
||||||
|
func (a *HTTPAdapter) SyncClaims(ctx context.Context, userID string, c Claims) error {
|
||||||
|
attrs := map[string][]string{
|
||||||
|
"tenant_id": {c.TenantID},
|
||||||
|
"tenant_slug": {c.TenantSlug},
|
||||||
|
"org_roles": c.OrgRoles,
|
||||||
|
"products": c.Products,
|
||||||
|
"plan": {c.Plan},
|
||||||
|
"tenant_status": {c.TenantStatus},
|
||||||
|
}
|
||||||
|
resp, err := a.adminCall(ctx, http.MethodPut, "/users/"+userID,
|
||||||
|
map[string]any{"attributes": attrs}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("sync claims: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) findOrgByAlias(ctx context.Context, alias string) (string, error) {
|
||||||
|
resp, err := a.adminCall(ctx, http.MethodGet,
|
||||||
|
fmt.Sprintf("/organizations?search=%s&exact=true", alias), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", fmt.Errorf("find org: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var orgs []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, o := range orgs {
|
||||||
|
if o.Alias == alias {
|
||||||
|
return o.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("org not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) findUserByEmail(ctx context.Context, email string) (string, error) {
|
||||||
|
resp, err := a.adminCall(ctx, http.MethodGet,
|
||||||
|
fmt.Sprintf("/users?email=%s&exact=true", email), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var users []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if strings.EqualFold(u.Email, email) {
|
||||||
|
return u.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) executeActionsEmail(ctx context.Context, userID string, actions []string, redirectURI string) (string, error) {
|
||||||
|
resp, err := a.adminCall(ctx, http.MethodPut,
|
||||||
|
fmt.Sprintf("/users/%s/execute-actions-email?client_id=dev-portal&redirect_uri=%s", userID, redirectURI),
|
||||||
|
actions, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", fmt.Errorf("execute-actions: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// KC doesn't return the action-token URL via this endpoint — it sends
|
||||||
|
// the email. For dev we surface an admin-portal pointer so the tester
|
||||||
|
// has somewhere to land.
|
||||||
|
return fmt.Sprintf("%s/realms/%s/account", a.cfg.BaseURL, a.cfg.Realm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastSegment(loc string) string {
|
||||||
|
if loc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path.Base(loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitName(full string) (first, last string) {
|
||||||
|
full = strings.TrimSpace(full)
|
||||||
|
if full == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := strings.Fields(full)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0], ""
|
||||||
|
}
|
||||||
|
return parts[0], strings.Join(parts[1:], " ")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,10 @@ func TestAuditAutoEmittedOnTenantCreate(t *testing.T) {
|
|||||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
"slug": "audit-target", "name": "Audit Target",
|
"slug": "audit-target", "name": "Audit Target",
|
||||||
})
|
})
|
||||||
fresh := decode[store.Tenant](t, body)
|
freshWrap := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
fresh := freshWrap.Tenant
|
||||||
|
|
||||||
resp, body := h.do("GET", "/v1/audit?action=tenant.created&tenant_id="+fresh.ID, nil)
|
resp, body := h.do("GET", "/v1/audit?action=tenant.created&tenant_id="+fresh.ID, nil)
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ func TestCatalogTrialRequest(t *testing.T) {
|
|||||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
"slug": "trial-target", "name": "Trial Target",
|
"slug": "trial-target", "name": "Trial Target",
|
||||||
})
|
})
|
||||||
fresh := decode[store.Tenant](t, body)
|
freshWrap := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
fresh := freshWrap.Tenant
|
||||||
|
|
||||||
resp, body := h.do("POST", "/v1/catalog/trial-request", map[string]any{
|
resp, body := h.do("POST", "/v1/catalog/trial-request", map[string]any{
|
||||||
"tenant_id": fresh.ID, "product": "compliance",
|
"tenant_id": fresh.ID, "product": "compliance",
|
||||||
|
|||||||
+13
-11
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,22 +88,23 @@ func (s *statusRecorder) WriteHeader(c int) {
|
|||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||||
return strings.TrimSpace(fwd[:i])
|
return stripBrackets(strings.TrimSpace(fwd[:i]))
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(fwd)
|
return stripBrackets(strings.TrimSpace(fwd))
|
||||||
}
|
}
|
||||||
if host, _, ok := splitHostPort(r.RemoteAddr); ok {
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
// net.SplitHostPort returns IPv6 without brackets already.
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
return r.RemoteAddr
|
return stripBrackets(r.RemoteAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't
|
// stripBrackets removes the `[...]` wrapping IPv6 hosts pick up from
|
||||||
// error on missing port.
|
// net/http's RemoteAddr in some Go versions, since Postgres `inet` rejects
|
||||||
func splitHostPort(s string) (string, string, bool) {
|
// `[::1]` but accepts `::1`.
|
||||||
i := strings.LastIndexByte(s, ':')
|
func stripBrackets(s string) string {
|
||||||
if i < 0 {
|
if len(s) >= 2 && s[0] == '[' && s[len(s)-1] == ']' {
|
||||||
return s, "", false
|
return s[1 : len(s)-1]
|
||||||
}
|
}
|
||||||
return s[:i], s[i+1:], true
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// provisionKeycloak is called inside createTenant after the DB insert
|
||||||
|
// succeeds. Best-effort: a failure does NOT roll the tenant back. The
|
||||||
|
// audit_log captures the error so the operator can heal it later
|
||||||
|
// (resending the invite is a one-click in the KC admin UI).
|
||||||
|
//
|
||||||
|
// Returns the InviteURL so the API response can surface it for dev.
|
||||||
|
func (s *Server) provisionKeycloak(ctx context.Context, t *store.Tenant, adminEmail, adminName string) (string, error) {
|
||||||
|
if adminEmail == "" {
|
||||||
|
// Skip silently — caller chose not to invite anyone yet (sales-led
|
||||||
|
// flow, demo tenant, test fixture, etc.).
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
res, err := s.Keycloak.CreateOrgAndInvite(ctx, keycloak.InviteInput{
|
||||||
|
TenantID: t.ID,
|
||||||
|
Slug: t.Slug,
|
||||||
|
Name: t.Name,
|
||||||
|
AdminEmail: adminEmail,
|
||||||
|
AdminName: adminName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.Log.Error("keycloak provision failed",
|
||||||
|
"tenant_id", t.ID, "slug", t.Slug, "err", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.Log.Info("keycloak provisioned",
|
||||||
|
"tenant_id", t.ID, "kc_org_id", res.OrganizationID, "kc_user_id", res.UserID)
|
||||||
|
return res.InviteURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kcClaims is POST /v1/internal/keycloak/claims. Called by Keycloak's
|
||||||
|
// protocol mapper (or by a dev tester) to fetch the current entitlement
|
||||||
|
// bundle for a user. Lookup chain:
|
||||||
|
// 1. body.tenant_slug → tenant
|
||||||
|
// 2. body.tenant_id → tenant
|
||||||
|
// 3. body.user_attrs.tenant_id → tenant
|
||||||
|
//
|
||||||
|
// At least one must be present.
|
||||||
|
type kcClaimsReq struct {
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||||
|
UserAttrs map[string]string `json:"user_attrs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) kcClaims(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in kcClaimsReq
|
||||||
|
if !decodeJSON(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := in.TenantID
|
||||||
|
if id == "" {
|
||||||
|
id = in.UserAttrs["tenant_id"]
|
||||||
|
}
|
||||||
|
slug := in.TenantSlug
|
||||||
|
if slug == "" {
|
||||||
|
slug = in.UserAttrs["tenant_slug"]
|
||||||
|
}
|
||||||
|
if id == "" && slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id or tenant_slug required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
t *store.Tenant
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if id != "" {
|
||||||
|
t, err = s.Store.GetTenant(ctx, id)
|
||||||
|
} else {
|
||||||
|
t, err = s.Store.GetTenantBySlug(ctx, slug)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "tenant does not exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
products, err := s.Store.ListTenantProducts(ctx, t.ID)
|
||||||
|
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productKeys := make([]string, 0, len(products))
|
||||||
|
for _, p := range products {
|
||||||
|
if p.Enabled {
|
||||||
|
productKeys = append(productKeys, p.Product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, keycloak.Claims{
|
||||||
|
TenantID: t.ID,
|
||||||
|
TenantSlug: t.Slug,
|
||||||
|
OrgRoles: []string{}, // populated by /v1/users/:id role lookup — out of scope until M5.2
|
||||||
|
Products: productKeys,
|
||||||
|
Plan: t.Plan,
|
||||||
|
TenantStatus: t.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateTenant_provisionsKeycloak(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "kc-co",
|
||||||
|
"name": "KC Co.",
|
||||||
|
"admin_email": "owner@kc-co.test",
|
||||||
|
"admin_name": "Pat Owner",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
InviteURL string `json:"invite_url"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Slug != "kc-co" {
|
||||||
|
t.Errorf("slug = %q", out.Tenant.Slug)
|
||||||
|
}
|
||||||
|
if out.InviteURL == "" {
|
||||||
|
t.Error("invite_url missing in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mock recorded the call.
|
||||||
|
if _, ok := h.kcMock.Orgs[out.Tenant.ID]; !ok {
|
||||||
|
t.Errorf("kc mock did not record org for tenant %s", out.Tenant.ID)
|
||||||
|
}
|
||||||
|
if _, ok := h.kcMock.Users["owner@kc-co.test"]; !ok {
|
||||||
|
t.Error("kc mock did not record user for owner@kc-co.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And we emitted a keycloak.invite_sent audit event.
|
||||||
|
resp, body = h.do("GET",
|
||||||
|
"/v1/audit?action=keycloak.invite_sent&tenant_id="+out.Tenant.ID, nil)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("audit list status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
listed := decode[struct {
|
||||||
|
Items []store.AuditEvent `json:"items"`
|
||||||
|
}](t, body)
|
||||||
|
if len(listed.Items) != 1 {
|
||||||
|
t.Errorf("expected 1 invite_sent event, got %d", len(listed.Items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTenant_kcFailure_doesNotRollback(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
// Force the mock to fail the next call.
|
||||||
|
h.kcMock.FailNext = keycloak.ErrUnavailable
|
||||||
|
|
||||||
|
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "kc-fail", "name": "KC Fail", "admin_email": "x@y.test",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("expected tenant still created despite kc fail; status=%d body=%s",
|
||||||
|
resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
// Tenant landed in the DB.
|
||||||
|
if out.Tenant.ID == "" {
|
||||||
|
t.Error("tenant id missing")
|
||||||
|
}
|
||||||
|
// And there's a provision_failed audit event for it.
|
||||||
|
_, body = h.do("GET",
|
||||||
|
"/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil)
|
||||||
|
listed := decode[struct {
|
||||||
|
Items []store.AuditEvent `json:"items"`
|
||||||
|
}](t, body)
|
||||||
|
if len(listed.Items) != 1 {
|
||||||
|
t.Errorf("expected 1 provision_failed event, got %d", len(listed.Items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcClaims_returnsCurrentEntitlements(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||||
|
"tenant_slug": h.tenant.Slug,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
got := decode[keycloak.Claims](t, body)
|
||||||
|
if got.TenantID != h.tenant.ID || got.TenantSlug != h.tenant.Slug {
|
||||||
|
t.Errorf("tenant fields off: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Plan != h.tenant.Plan {
|
||||||
|
t.Errorf("plan = %q, want %q", got.Plan, h.tenant.Plan)
|
||||||
|
}
|
||||||
|
if got.TenantStatus != h.tenant.Status {
|
||||||
|
t.Errorf("status = %q, want %q", got.TenantStatus, h.tenant.Status)
|
||||||
|
}
|
||||||
|
// acme is seeded with certifai + compliance entitlements (memory)
|
||||||
|
// or one or zero (postgres, depending on prior subtest ordering).
|
||||||
|
// At minimum the field is present.
|
||||||
|
if got.Products == nil {
|
||||||
|
t.Error("products is nil; should be at least empty slice")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcClaims_lookupByUserAttrs(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||||
|
"user_attrs": map[string]string{"tenant_slug": h.tenant.Slug},
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
got := decode[keycloak.Claims](t, body)
|
||||||
|
if got.TenantID != h.tenant.ID {
|
||||||
|
t.Errorf("did not resolve via user_attrs; got %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcClaims_missingTenant404(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
||||||
|
"tenant_slug": "nope-nope",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcClaims_requiresInput(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{})
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ func TestOpenAPISpec_loadsAndIsConsistent(t *testing.T) {
|
|||||||
{"GET", "/v1/api-keys?tenant_id=00000000-0000-0000-0000-000000000001"},
|
{"GET", "/v1/api-keys?tenant_id=00000000-0000-0000-0000-000000000001"},
|
||||||
{"GET", "/v1/catalog"},
|
{"GET", "/v1/catalog"},
|
||||||
{"GET", "/v1/audit?limit=10"},
|
{"GET", "/v1/audit?limit=10"},
|
||||||
|
{"POST", "/v1/internal/keycloak/claims"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
req := newRequest(t, c.method, c.path)
|
req := newRequest(t, c.method, c.path)
|
||||||
|
|||||||
+62
-14
@@ -1,31 +1,44 @@
|
|||||||
// Package server wires the HTTP surface for tenant-registry.
|
// Package server wires the HTTP surface for tenant-registry.
|
||||||
//
|
//
|
||||||
// All routes are registered in NewRouter; per-concern handlers live in
|
// All routes are registered in NewRouter; per-concern handlers live in
|
||||||
// peer files (tenants.go, catalog.go, apikeys.go, audit.go).
|
// peer files (tenants.go, catalog.go, apikeys.go, audit.go, keycloak.go).
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"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/config"
|
||||||
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server bundles the dependencies every handler needs.
|
// Server bundles the dependencies every handler needs.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
Store store.Store
|
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.
|
// 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 {
|
func NewRouter(s *Server) http.Handler {
|
||||||
mux := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
|
|
||||||
// health + status
|
// PUBLIC_EXPLICIT: health + status probes.
|
||||||
mux.HandleFunc("GET /healthz", s.healthz)
|
root.HandleFunc("GET /healthz", s.healthz)
|
||||||
mux.HandleFunc("GET /readyz", s.readyz)
|
root.HandleFunc("GET /readyz", s.readyz)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// tenants
|
// tenants
|
||||||
mux.HandleFunc("POST /v1/tenants", s.createTenant)
|
mux.HandleFunc("POST /v1/tenants", s.createTenant)
|
||||||
@@ -34,9 +47,7 @@ func NewRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
|
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
|
||||||
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
|
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
|
||||||
|
|
||||||
// entitlements — top-level path so it doesn't conflict with
|
// entitlements
|
||||||
// /v1/tenants/by-slug/{slug} (Go 1.22 ServeMux can't disambiguate
|
|
||||||
// /v1/tenants/{id}/products vs /v1/tenants/by-slug/{slug=products}).
|
|
||||||
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
|
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
|
||||||
|
|
||||||
// catalog
|
// catalog
|
||||||
@@ -44,8 +55,7 @@ func NewRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
|
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
|
||||||
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
|
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
|
||||||
|
|
||||||
// api keys — same disambiguation: list lives at /v1/api-keys?tenant_id=X
|
// api keys
|
||||||
// instead of /v1/tenants/{id}/api-keys.
|
|
||||||
mux.HandleFunc("POST /v1/api-keys", s.createAPIKey)
|
mux.HandleFunc("POST /v1/api-keys", s.createAPIKey)
|
||||||
mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys)
|
mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys)
|
||||||
mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey)
|
mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey)
|
||||||
@@ -55,7 +65,41 @@ func NewRouter(s *Server) http.Handler {
|
|||||||
mux.HandleFunc("POST /v1/audit", s.appendAudit)
|
mux.HandleFunc("POST /v1/audit", s.appendAudit)
|
||||||
mux.HandleFunc("GET /v1/audit", s.listAudit)
|
mux.HandleFunc("GET /v1/audit", s.listAudit)
|
||||||
|
|
||||||
return logRequest(s.Log)(mux)
|
// keycloak claims refresh — the URL the protocol mapper would call at
|
||||||
|
// token issuance to grab the up-to-date entitlement bundle. Today the
|
||||||
|
// dev realm projects user attributes (set by SyncClaims) — this is
|
||||||
|
// the "pull" complement for when the realm is reconfigured to fetch.
|
||||||
|
mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims)
|
||||||
|
|
||||||
|
// 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) {
|
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -67,5 +111,9 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := s.Keycloak.Health(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
|
||||||
"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/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"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/migrations"
|
"gitea.meghsakha.com/platform/tenant-registry/migrations"
|
||||||
@@ -33,6 +34,7 @@ type testHarness struct {
|
|||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
store store.Store
|
store store.Store
|
||||||
tenant *store.Tenant // pre-created acme tenant
|
tenant *store.Tenant // pre-created acme tenant
|
||||||
|
kcMock *keycloak.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *testHarness) Close() {
|
func (h *testHarness) Close() {
|
||||||
@@ -131,14 +133,19 @@ func newPostgresHarness(t *testing.T) *testHarness {
|
|||||||
func wireHarness(t *testing.T, s store.Store, seed *store.Tenant) *testHarness {
|
func wireHarness(t *testing.T, s store.Store, seed *store.Tenant) *testHarness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
mock := keycloak.NewMock()
|
||||||
handler := server.NewRouter(&server.Server{
|
handler := server.NewRouter(&server.Server{
|
||||||
Cfg: &config.Config{Env: "dev"}, Log: logger, Store: s,
|
Cfg: &config.Config{Env: "dev"},
|
||||||
|
Log: logger,
|
||||||
|
Store: s,
|
||||||
|
Keycloak: mock,
|
||||||
})
|
})
|
||||||
return &testHarness{
|
return &testHarness{
|
||||||
t: t,
|
t: t,
|
||||||
srv: httptest.NewServer(handler),
|
srv: httptest.NewServer(handler),
|
||||||
store: s,
|
store: s,
|
||||||
tenant: seed,
|
tenant: seed,
|
||||||
|
kcMock: mock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ type createTenantReq struct {
|
|||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
Kind string `json:"kind,omitempty"`
|
Kind string `json:"kind,omitempty"`
|
||||||
SalesOwner string `json:"sales_owner,omitempty"`
|
SalesOwner string `json:"sales_owner,omitempty"`
|
||||||
|
// AdminEmail is optional. When set, the Keycloak adapter provisions
|
||||||
|
// an organization + invites this user as IT_ADMIN. Omitted for
|
||||||
|
// sales-led flows that invite the admin later via the portal.
|
||||||
|
AdminEmail string `json:"admin_email,omitempty"`
|
||||||
|
AdminName string `json:"admin_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenantResp wraps the tenant with the optional KC invite URL so
|
||||||
|
// dev testers can use it without waiting for the email.
|
||||||
|
type createTenantResp struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
InviteURL string `json:"invite_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -40,7 +52,7 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
t, err := s.Store.CreateTenant(ctx, store.TenantCreate{
|
t, err := s.Store.CreateTenant(ctx, store.TenantCreate{
|
||||||
Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner,
|
Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner,
|
||||||
@@ -63,7 +75,25 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
|
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
|
||||||
})
|
})
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, t)
|
// Best-effort Keycloak provisioning. A failure here doesn't roll the
|
||||||
|
// tenant back — the operator can resend the invite via the KC admin UI.
|
||||||
|
// We emit an audit event regardless so the failure is traceable.
|
||||||
|
inviteURL, kcErr := s.provisionKeycloak(ctx, t, in.AdminEmail, in.AdminName)
|
||||||
|
if kcErr != nil {
|
||||||
|
s.emitAudit(ctx, r, store.AuditEvent{
|
||||||
|
TenantID: t.ID, Action: "keycloak.provision_failed",
|
||||||
|
TargetID: t.ID, TargetType: "tenant",
|
||||||
|
Metadata: map[string]interface{}{"err": kcErr.Error(), "admin_email": in.AdminEmail},
|
||||||
|
})
|
||||||
|
} else if in.AdminEmail != "" {
|
||||||
|
s.emitAudit(ctx, r, store.AuditEvent{
|
||||||
|
TenantID: t.ID, Action: "keycloak.invite_sent",
|
||||||
|
TargetID: in.AdminEmail, TargetType: "user", TargetName: in.AdminEmail,
|
||||||
|
Metadata: map[string]interface{}{"role": "IT_ADMIN"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
@@ -24,9 +25,12 @@ func TestCreateTenant(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
t1 := decode[store.Tenant](t, body)
|
out := decode[struct {
|
||||||
if t1.Slug != "beta-co" || t1.Status != "trial" || t1.Plan != "starter" {
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
t.Errorf("unexpected: %+v", t1)
|
InviteURL string `json:"invite_url"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Slug != "beta-co" || out.Tenant.Status != "trial" || out.Tenant.Plan != "starter" {
|
||||||
|
t.Errorf("unexpected: %+v", out.Tenant)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -81,7 +85,10 @@ func TestActivateTenant(t *testing.T) {
|
|||||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
"slug": "trial-co", "name": "Trial Co.",
|
"slug": "trial-co", "name": "Trial Co.",
|
||||||
})
|
})
|
||||||
created := decode[store.Tenant](t, body)
|
createdWrap := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
created := createdWrap.Tenant
|
||||||
if created.Status != "trial" {
|
if created.Status != "trial" {
|
||||||
t.Fatalf("precondition: %q", created.Status)
|
t.Fatalf("precondition: %q", created.Status)
|
||||||
}
|
}
|
||||||
@@ -113,3 +120,42 @@ func TestCancelTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTenant_setsTrialEndsAt(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "trial-ends-co", "name": "Trial Ends Co.",
|
||||||
|
})
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Status != "trial" {
|
||||||
|
t.Fatalf("status = %q, want trial", out.Tenant.Status)
|
||||||
|
}
|
||||||
|
if out.Tenant.TrialEndsAt == nil {
|
||||||
|
t.Fatal("trial_ends_at is nil; should be ~14 days from now")
|
||||||
|
}
|
||||||
|
// Sanity-check: ends_at is in the future, within 13.5-14.5 days.
|
||||||
|
delta := time.Until(*out.Tenant.TrialEndsAt)
|
||||||
|
if delta < 13*24*time.Hour || delta > 15*24*time.Hour {
|
||||||
|
t.Errorf("trial_ends_at offset = %v, want ~14d", delta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTenant_demoKindHasNoTrialEnd(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "demo-co", "name": "Demo", "kind": "demo",
|
||||||
|
})
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Status != "demo" {
|
||||||
|
t.Errorf("status = %q, want demo", out.Tenant.Status)
|
||||||
|
}
|
||||||
|
if out.Tenant.TrialEndsAt != nil {
|
||||||
|
t.Errorf("trial_ends_at = %v, want nil for demo kind", out.Tenant.TrialEndsAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -69,16 +69,26 @@ func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, erro
|
|||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
kind := firstNonEmpty(in.Kind, "customer")
|
||||||
|
status := "trial"
|
||||||
|
var trialEnds *time.Time
|
||||||
|
if kind == "demo" {
|
||||||
|
status = "demo"
|
||||||
|
} else {
|
||||||
|
end := now.Add(14 * 24 * time.Hour)
|
||||||
|
trialEnds = &end
|
||||||
|
}
|
||||||
t := &Tenant{
|
t := &Tenant{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Slug: in.Slug,
|
Slug: in.Slug,
|
||||||
Name: in.Name,
|
Name: in.Name,
|
||||||
Status: "trial",
|
Status: status,
|
||||||
Kind: firstNonEmpty(in.Kind, "customer"),
|
Kind: kind,
|
||||||
Plan: firstNonEmpty(in.Plan, "starter"),
|
Plan: firstNonEmpty(in.Plan, "starter"),
|
||||||
SalesOwner: in.SalesOwner,
|
SalesOwner: in.SalesOwner,
|
||||||
CreatedAt: now,
|
TrialEndsAt: trialEnds,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
m.tenants[t.ID] = t
|
m.tenants[t.ID] = t
|
||||||
m.bySlug[t.Slug] = t.ID
|
m.bySlug[t.Slug] = t.ID
|
||||||
|
|||||||
@@ -90,9 +90,20 @@ func scanTenant(row pgx.Row) (*Tenant, error) {
|
|||||||
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
||||||
kind := firstNonEmpty(in.Kind, "customer")
|
kind := firstNonEmpty(in.Kind, "customer")
|
||||||
plan := firstNonEmpty(in.Plan, "starter")
|
plan := firstNonEmpty(in.Plan, "starter")
|
||||||
|
// Default status = 'trial'; set trial_ends_at = NOW() + 14 days so the
|
||||||
|
// portal's trial banner has a real countdown to render. Demo tenants
|
||||||
|
// (kind=demo) get status='demo' and no trial_ends_at — that's set by
|
||||||
|
// the M13.2 demo provisioning path.
|
||||||
row := p.pool.QueryRow(ctx,
|
row := p.pool.QueryRow(ctx,
|
||||||
`INSERT INTO tenants (slug, name, kind, plan, sales_owner)
|
`INSERT INTO tenants (slug, name, kind, plan, status, sales_owner, trial_ends_at)
|
||||||
VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, ''))
|
VALUES (
|
||||||
|
$1, $2, $3::tenant_kind, $4,
|
||||||
|
CASE WHEN $3::tenant_kind = 'demo' THEN 'demo'::tenant_status
|
||||||
|
ELSE 'trial'::tenant_status END,
|
||||||
|
NULLIF($5, ''),
|
||||||
|
CASE WHEN $3::tenant_kind = 'demo' THEN NULL
|
||||||
|
ELSE NOW() + INTERVAL '14 days' END
|
||||||
|
)
|
||||||
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
||||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||||
|
|||||||
+114
-3
@@ -8,7 +8,10 @@ info:
|
|||||||
`PLATFORM_ARCHITECTURE.md §5c` for the schema, and
|
`PLATFORM_ARCHITECTURE.md §5c` for the schema, and
|
||||||
`PRODUCT_INTEGRATION_SPEC.md §8.4` for the audit shape.
|
`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:
|
contact:
|
||||||
email: oncall@breakpilot.com
|
email: oncall@breakpilot.com
|
||||||
license:
|
license:
|
||||||
@@ -48,15 +51,22 @@ paths:
|
|||||||
/v1/tenants:
|
/v1/tenants:
|
||||||
post:
|
post:
|
||||||
summary: Create a tenant.
|
summary: Create a tenant.
|
||||||
|
description: |
|
||||||
|
Creates the tenant row, and if `admin_email` is provided, also
|
||||||
|
creates a Keycloak organization + invites the user as IT_ADMIN.
|
||||||
|
Keycloak failures DO NOT roll the tenant back — they emit a
|
||||||
|
`keycloak.provision_failed` audit event so the operator can resend
|
||||||
|
the invite from the KC admin UI.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json: { schema: { $ref: "#/components/schemas/TenantCreate" } }
|
application/json: { schema: { $ref: "#/components/schemas/TenantCreate" } }
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Created.
|
description: Created. `invite_url` is non-empty when an
|
||||||
|
`admin_email` was passed and Keycloak provisioning succeeded.
|
||||||
content:
|
content:
|
||||||
application/json: { schema: { $ref: "#/components/schemas/Tenant" } }
|
application/json: { schema: { $ref: "#/components/schemas/TenantCreated" } }
|
||||||
"400": { $ref: "#/components/responses/BadRequest" }
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
"409": { $ref: "#/components/responses/Conflict" }
|
"409": { $ref: "#/components/responses/Conflict" }
|
||||||
|
|
||||||
@@ -243,6 +253,78 @@ paths:
|
|||||||
description: Revoked.
|
description: Revoked.
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"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.
|
||||||
|
description: |
|
||||||
|
Called by Keycloak's protocol mapper at token issuance (or by
|
||||||
|
any operator on demand) to fetch the current tenant_id /
|
||||||
|
tenant_slug / org_roles / products / plan / tenant_status
|
||||||
|
claims. Lookup tries tenant_id, then tenant_slug, then
|
||||||
|
user_attrs.tenant_id, then user_attrs.tenant_slug.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tenant_id: { type: string, format: uuid }
|
||||||
|
tenant_slug: { type: string }
|
||||||
|
user_attrs:
|
||||||
|
type: object
|
||||||
|
additionalProperties: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Resolved claim bundle.
|
||||||
|
content:
|
||||||
|
application/json: { schema: { $ref: "#/components/schemas/Claims" } }
|
||||||
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
/v1/internal/api-keys/verify:
|
/v1/internal/api-keys/verify:
|
||||||
post:
|
post:
|
||||||
summary: Verify an API key. Used by headless products. Returns
|
summary: Verify an API key. Used by headless products. Returns
|
||||||
@@ -320,6 +402,15 @@ paths:
|
|||||||
"400": { $ref: "#/components/responses/BadRequest" }
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
|
||||||
components:
|
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:
|
responses:
|
||||||
BadRequest:
|
BadRequest:
|
||||||
description: Input failed validation.
|
description: Input failed validation.
|
||||||
@@ -335,6 +426,17 @@ components:
|
|||||||
application/json: { schema: { $ref: "#/components/schemas/Error" } }
|
application/json: { schema: { $ref: "#/components/schemas/Error" } }
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
Claims:
|
||||||
|
type: object
|
||||||
|
required: [tenant_id, tenant_slug, plan, tenant_status]
|
||||||
|
properties:
|
||||||
|
tenant_id: { type: string, format: uuid }
|
||||||
|
tenant_slug: { type: string }
|
||||||
|
org_roles: { type: array, items: { type: string } }
|
||||||
|
products: { type: array, items: { type: string } }
|
||||||
|
plan: { type: string }
|
||||||
|
tenant_status: { type: string, enum: [demo, trial, active, frozen, archived] }
|
||||||
|
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
required: [error]
|
required: [error]
|
||||||
@@ -370,6 +472,15 @@ components:
|
|||||||
plan: { type: string, default: starter }
|
plan: { type: string, default: starter }
|
||||||
kind: { type: string, enum: [customer, demo], default: customer }
|
kind: { type: string, enum: [customer, demo], default: customer }
|
||||||
sales_owner: { type: string }
|
sales_owner: { type: string }
|
||||||
|
admin_email: { type: string, format: email, description: "IT_ADMIN to invite via Keycloak" }
|
||||||
|
admin_name: { type: string }
|
||||||
|
|
||||||
|
TenantCreated:
|
||||||
|
type: object
|
||||||
|
required: [tenant]
|
||||||
|
properties:
|
||||||
|
tenant: { $ref: "#/components/schemas/Tenant" }
|
||||||
|
invite_url: { type: string, description: "KC action-token URL — present only when admin_email was set and KC provisioning succeeded" }
|
||||||
|
|
||||||
TenantActivate:
|
TenantActivate:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user