Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67264d57ed |
+1
-14
@@ -13,7 +13,6 @@ import (
|
|||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
@@ -75,19 +74,7 @@ func main() {
|
|||||||
slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)")
|
slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Downstream product provisioning. Wired only when PRODUCT_API_URL is set;
|
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av})
|
||||||
// otherwise a no-op so an unconfigured deployment still creates tenants.
|
|
||||||
// The product's gate is not yet enforcing, so no token is attached today —
|
|
||||||
// AuthHeaderFunc is the seam for when it is.
|
|
||||||
var pv product.Provisioner = product.NoopProvisioner{}
|
|
||||||
if cfg.ProductAPIURL != "" {
|
|
||||||
pv = product.NewHTTPProvisioner(cfg.ProductAPIURL, cfg.ProductAPIPath, nil, cfg.ProductTimeout)
|
|
||||||
slog.Info("product provisioner configured", "url", cfg.ProductAPIURL, "path", cfg.ProductAPIPath)
|
|
||||||
} else {
|
|
||||||
slog.Warn("PRODUCT_API_URL not set — product tenant provisioning disabled (anchors may diverge)")
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av, Product: pv})
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ type Config struct {
|
|||||||
// contains AuthAudience.
|
// contains AuthAudience.
|
||||||
AuthEnabled bool
|
AuthEnabled bool
|
||||||
AuthAudience string
|
AuthAudience string
|
||||||
|
|
||||||
// Downstream product provisioning. When ProductAPIURL is set, tenant
|
|
||||||
// creation also provisions the tenant in the product's own database with
|
|
||||||
// the SAME registry UUID, so both anchors agree. Empty ⇒ no-op adapter.
|
|
||||||
ProductAPIURL string
|
|
||||||
ProductAPIPath string
|
|
||||||
ProductTimeout time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -56,10 +49,6 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
|
AuthEnabled: getenv("AUTH_ENABLED", "false") == "true",
|
||||||
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
|
AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"),
|
||||||
|
|
||||||
ProductAPIURL: os.Getenv("PRODUCT_API_URL"),
|
|
||||||
ProductAPIPath: getenv("PRODUCT_API_TENANTS_PATH", "/sdk/v1/tenants"),
|
|
||||||
ProductTimeout: 10 * time.Second,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
// Package product provisions a tenant into the downstream product database.
|
|
||||||
//
|
|
||||||
// The registry is the authority for tenant identity (ratified model B2), so a
|
|
||||||
// product must anchor its own tenant row to the SAME canonical UUID. Without
|
|
||||||
// this the anchors diverge: the registry knows "acme", the product database
|
|
||||||
// does not, and the product's gate rejects every request from that tenant
|
|
||||||
// because it cannot resolve the org slug.
|
|
||||||
//
|
|
||||||
// The Provisioner is a port, mirroring keycloak.Adapter: handlers depend on the
|
|
||||||
// interface, main wires the HTTP implementation when PRODUCT_API_URL is set and
|
|
||||||
// a no-op otherwise (dev convenience, and so an unconfigured deployment does
|
|
||||||
// not fail tenant creation).
|
|
||||||
package product
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrUnavailable means the product API could not be reached or refused us.
|
|
||||||
var ErrUnavailable = errors.New("product: provisioning API unavailable")
|
|
||||||
|
|
||||||
// Tenant is the minimal identity a product needs to create its own row.
|
|
||||||
type Tenant struct {
|
|
||||||
ID string `json:"id"` // the registry UUID — the whole point
|
|
||||||
Name string `json:"name"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provisioner creates the tenant in a product's own datastore.
|
|
||||||
type Provisioner interface {
|
|
||||||
ProvisionTenant(ctx context.Context, t Tenant) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoopProvisioner is wired when PRODUCT_API_URL is unset.
|
|
||||||
type NoopProvisioner struct{}
|
|
||||||
|
|
||||||
func (NoopProvisioner) ProvisionTenant(context.Context, Tenant) error { return nil }
|
|
||||||
|
|
||||||
// AuthHeaderFunc supplies an Authorization header per call, so a token is
|
|
||||||
// fetched lazily and refreshed rather than captured at construction. Nil means
|
|
||||||
// no header, which is the correct behaviour against a product whose gate is
|
|
||||||
// not yet enforcing.
|
|
||||||
type AuthHeaderFunc func(context.Context) (string, error)
|
|
||||||
|
|
||||||
// HTTPProvisioner calls the product's tenant-create endpoint.
|
|
||||||
type HTTPProvisioner struct {
|
|
||||||
BaseURL string
|
|
||||||
Path string // e.g. /sdk/v1/tenants
|
|
||||||
Auth AuthHeaderFunc
|
|
||||||
Client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTPProvisioner(baseURL, path string, auth AuthHeaderFunc, timeout time.Duration) *HTTPProvisioner {
|
|
||||||
if path == "" {
|
|
||||||
path = "/sdk/v1/tenants"
|
|
||||||
}
|
|
||||||
return &HTTPProvisioner{
|
|
||||||
BaseURL: baseURL, Path: path, Auth: auth,
|
|
||||||
Client: &http.Client{Timeout: timeout},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProvisioner) ProvisionTenant(ctx context.Context, t Tenant) error {
|
|
||||||
body, err := json.Marshal(t)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+p.Path, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if p.Auth != nil {
|
|
||||||
h, aerr := p.Auth(ctx)
|
|
||||||
if aerr != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrUnavailable, aerr)
|
|
||||||
}
|
|
||||||
if h != "" {
|
|
||||||
req.Header.Set("Authorization", h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp, err := p.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case resp.StatusCode == http.StatusConflict:
|
|
||||||
// Already provisioned. The product's insert is idempotent on the
|
|
||||||
// primary key, so this is success from our point of view.
|
|
||||||
return nil
|
|
||||||
case resp.StatusCode/100 == 2:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return fmt.Errorf("%w: %d %s", ErrUnavailable, resp.StatusCode, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package product
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The whole point of this port: the product must be created with the
|
|
||||||
// REGISTRY's uuid, so both systems anchor to the same tenant identity.
|
|
||||||
func TestProvisionTenant_sendsTheRegistryUUID(t *testing.T) {
|
|
||||||
var got Tenant
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
p := NewHTTPProvisioner(srv.URL, "/sdk/v1/tenants", nil, 5*time.Second)
|
|
||||||
err := p.ProvisionTenant(context.Background(), Tenant{
|
|
||||||
ID: "2f875d6a-1d94-433a-b2ec-8529451a2d89", Name: "Acme", Slug: "acme",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" {
|
|
||||||
t.Errorf("registry uuid not sent: %+v", got)
|
|
||||||
}
|
|
||||||
if got.Slug != "acme" || got.Name != "Acme" {
|
|
||||||
t.Errorf("payload wrong: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-provisioning must be safe: onboarding may retry, and the product's
|
|
||||||
// insert is idempotent on the primary key.
|
|
||||||
func TestProvisionTenant_conflictIsSuccess(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
|
||||||
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
|
||||||
t.Fatalf("409 should be treated as already-provisioned, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProvisionTenant_serverErrorIsUnavailable(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
p := NewHTTPProvisioner(srv.URL, "", nil, 5*time.Second)
|
|
||||||
err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"})
|
|
||||||
if !errors.Is(err, ErrUnavailable) {
|
|
||||||
t.Fatalf("want ErrUnavailable, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProvisionTenant_attachesAuthHeaderWhenSupplied(t *testing.T) {
|
|
||||||
var seen string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
seen = r.Header.Get("Authorization")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
p := NewHTTPProvisioner(srv.URL, "", func(context.Context) (string, error) {
|
|
||||||
return "Bearer tok-123", nil
|
|
||||||
}, 5*time.Second)
|
|
||||||
if err := p.ProvisionTenant(context.Background(), Tenant{ID: "x"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if seen != "Bearer tok-123" {
|
|
||||||
t.Errorf("auth header = %q", seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoopProvisioner(t *testing.T) {
|
|
||||||
if err := (NoopProvisioner{}).ProvisionTenant(context.Background(), Tenant{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,17 +22,6 @@ type Server struct {
|
|||||||
Store store.Store
|
Store store.Store
|
||||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
||||||
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
|
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
|
||||||
Product product.Provisioner // never nil — main wires Noop when PRODUCT_API_URL is unset
|
|
||||||
}
|
|
||||||
|
|
||||||
// productProvisioner guarantees the "never nil" invariant the struct documents.
|
|
||||||
// Tests construct Server directly and would otherwise panic; an unset provisioner
|
|
||||||
// simply means no downstream provisioning, never a crash mid-tenant-creation.
|
|
||||||
func (s *Server) productProvisioner() product.Provisioner {
|
|
||||||
if s.Product == nil {
|
|
||||||
return product.NoopProvisioner{}
|
|
||||||
}
|
|
||||||
return s.Product
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter builds the http.Handler with logging middleware applied.
|
// NewRouter builds the http.Handler with logging middleware applied.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,27 +93,6 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort product provisioning, same contract as Keycloak above: the
|
|
||||||
// registry is the authority for tenant identity, so the product gets a row
|
|
||||||
// keyed by OUR uuid. A failure must not roll the tenant back — it is
|
|
||||||
// recorded as an audit event so the divergence is traceable and fixable.
|
|
||||||
// Without this the anchors drift and the product's gate rejects every
|
|
||||||
// request from the tenant because it cannot resolve the org slug.
|
|
||||||
if perr := s.productProvisioner().ProvisionTenant(ctx, product.Tenant{
|
|
||||||
ID: t.ID, Name: t.Name, Slug: t.Slug,
|
|
||||||
}); perr != nil {
|
|
||||||
s.emitAudit(ctx, r, store.AuditEvent{
|
|
||||||
TenantID: t.ID, Action: "product.provision_failed",
|
|
||||||
TargetID: t.ID, TargetType: "tenant",
|
|
||||||
Metadata: map[string]interface{}{"err": perr.Error()},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
s.emitAudit(ctx, r, store.AuditEvent{
|
|
||||||
TenantID: t.ID, Action: "product.tenant_provisioned",
|
|
||||||
TargetID: t.ID, TargetType: "tenant",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user