Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips. The registry is the authority for tenant identity (model B2), but nothing ever told the product about a new tenant. The anchors drifted: the registry held acme/matrix-acme while the product database held only the legacy seed tenant 9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and would 403 EVERY authenticated request, because no real user's org slug existed locally. New internal/product port, mirroring keycloak.Adapter: handlers depend on the interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and NoopProvisioner otherwise, so an unconfigured deployment still creates tenants. Tenant creation now also provisions the product tenant with OUR uuid, using the same best-effort contract as Keycloak provisioning: a failure does not roll the tenant back, it emits a product.provision_failed audit event so the divergence is traceable. Success emits product.tenant_provisioned. A 409 from the product counts as success — onboarding may retry and the product's insert is idempotent on the primary key (compliance#217). Server.productProvisioner() guarantees the documented never-nil invariant; tests construct Server directly and would otherwise panic mid-tenant-creation. 5 tests incl. the point of the whole port (the registry UUID is what gets sent). Full suite green with -race, coverage 71.4% (gate 70). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
131 lines
4.0 KiB
Go
131 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
slog.SetDefault(logger)
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
slog.Error("config load failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
bootCtx, cancelBoot := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancelBoot()
|
|
|
|
var s store.Store
|
|
if cfg.DatabaseURL == "" {
|
|
slog.Warn("DATABASE_URL not set — running with in-memory store (dev only)")
|
|
s = store.NewMemory()
|
|
} else {
|
|
pg, err := store.NewPostgres(bootCtx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
slog.Error("postgres connect failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
s = pg
|
|
}
|
|
defer s.Close()
|
|
|
|
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)")
|
|
}
|
|
|
|
// Downstream product provisioning. Wired only when PRODUCT_API_URL is set;
|
|
// otherwise a no-op so an unconfigured deployment still creates tenants.
|
|
// The product's gate is not yet enforcing, so no token is attached today —
|
|
// AuthHeaderFunc is the seam for when it is.
|
|
var pv product.Provisioner = product.NoopProvisioner{}
|
|
if cfg.ProductAPIURL != "" {
|
|
pv = product.NewHTTPProvisioner(cfg.ProductAPIURL, cfg.ProductAPIPath, nil, cfg.ProductTimeout)
|
|
slog.Info("product provisioner configured", "url", cfg.ProductAPIURL, "path", cfg.ProductAPIPath)
|
|
} else {
|
|
slog.Warn("PRODUCT_API_URL not set — product tenant provisioning disabled (anchors may diverge)")
|
|
}
|
|
|
|
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av, Product: pv})
|
|
srv := &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: handler,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
go func() {
|
|
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env, "store", storeKind(s))
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.Error("server crashed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
slog.Info("shutdown requested")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("shutdown failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("bye")
|
|
}
|
|
|
|
func storeKind(s store.Store) string {
|
|
switch s.(type) {
|
|
case *store.Memory:
|
|
return "memory"
|
|
case *store.Postgres:
|
|
return "postgres"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|