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
72 lines
2.3 KiB
Go
72 lines
2.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Env string // dev | stage | prod
|
|
Addr string // listen address, e.g. ":8090"
|
|
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
|
|
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
|
|
|
|
// Downstream product provisioning. When ProductAPIURL is set, tenant
|
|
// creation also provisions the tenant in the product's own database with
|
|
// the SAME registry UUID, so both anchors agree. Empty ⇒ no-op adapter.
|
|
ProductAPIURL string
|
|
ProductAPIPath string
|
|
ProductTimeout time.Duration
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
env := getenv("APP_ENV", "dev")
|
|
if env != "dev" && env != "stage" && env != "prod" {
|
|
return nil, fmt.Errorf("invalid APP_ENV %q", env)
|
|
}
|
|
return &Config{
|
|
Env: env,
|
|
// :8090 — Keycloak owns :8080 in the dev stack.
|
|
Addr: getenv("ADDR", ":8090"),
|
|
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
|
|
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"),
|
|
|
|
ProductAPIURL: os.Getenv("PRODUCT_API_URL"),
|
|
ProductAPIPath: getenv("PRODUCT_API_TENANTS_PATH", "/sdk/v1/tenants"),
|
|
ProductTimeout: 10 * time.Second,
|
|
}, nil
|
|
}
|
|
|
|
func getenv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|