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
277 lines
8.8 KiB
Go
277 lines
8.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/product"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
|
)
|
|
|
|
// slug validation mirrors the schema CHECK in 0001_init.up.sql so we reject
|
|
// at the API boundary rather than waiting for the DB to do it.
|
|
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`)
|
|
|
|
type createTenantReq struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Kind string `json:"kind,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) {
|
|
var in createTenantReq
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if !slugRE.MatchString(in.Slug) {
|
|
writeError(w, http.StatusBadRequest, "invalid_slug", "slug must match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$")
|
|
return
|
|
}
|
|
if in.Name == "" || len(in.Name) > 255 {
|
|
writeError(w, http.StatusBadRequest, "invalid_name", "name must be 1..255 chars")
|
|
return
|
|
}
|
|
if in.Kind != "" && in.Kind != "customer" && in.Kind != "demo" {
|
|
writeError(w, http.StatusBadRequest, "invalid_kind", "kind must be 'customer' or 'demo'")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
t, err := s.Store.CreateTenant(ctx, store.TenantCreate{
|
|
Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner,
|
|
})
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
s.Log.Error("create tenant failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal", "create failed")
|
|
return
|
|
}
|
|
|
|
s.emitAudit(ctx, r, store.AuditEvent{
|
|
TenantID: t.ID,
|
|
Action: "tenant.created",
|
|
TargetID: t.ID,
|
|
TargetType: "tenant",
|
|
TargetName: t.Slug,
|
|
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
|
|
})
|
|
|
|
// 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"},
|
|
})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
t, err := s.Store.GetTenant(ctx, r.PathValue("id"))
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
func (s *Server) getTenantBySlug(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
t, err := s.Store.GetTenantBySlug(ctx, r.PathValue("slug"))
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
type activateReq struct {
|
|
Plan string `json:"plan,omitempty"`
|
|
ContractStart *string `json:"contract_start,omitempty"` // YYYY-MM-DD
|
|
ContractEnd *string `json:"contract_end,omitempty"`
|
|
ErpCustomerID string `json:"erp_customer_id,omitempty"`
|
|
}
|
|
|
|
func (s *Server) activateTenant(w http.ResponseWriter, r *http.Request) {
|
|
var in activateReq
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
upd := store.TenantUpdate{Status: ptrStr("active")}
|
|
if in.Plan != "" {
|
|
upd.Plan = &in.Plan
|
|
}
|
|
if in.ErpCustomerID != "" {
|
|
upd.ErpCustomerID = &in.ErpCustomerID
|
|
}
|
|
if cs, err := parseDate(in.ContractStart); err == nil && cs != nil {
|
|
upd.ContractStart = cs
|
|
} else if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_contract_start", "must be YYYY-MM-DD")
|
|
return
|
|
}
|
|
if ce, err := parseDate(in.ContractEnd); err == nil && ce != nil {
|
|
upd.ContractEnd = ce
|
|
} else if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_contract_end", "must be YYYY-MM-DD")
|
|
return
|
|
}
|
|
|
|
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), upd)
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
|
|
s.emitAudit(ctx, r, store.AuditEvent{
|
|
TenantID: t.ID, Action: "tenant.activated", TargetID: t.ID, TargetType: "tenant",
|
|
Metadata: map[string]interface{}{"plan": t.Plan, "erp_customer_id": t.ErpCustomerID},
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
type cancelReq struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
// AtPeriodEnd is a hint to billing; we always flip to 'frozen' immediately
|
|
// since billing is out of scope here.
|
|
AtPeriodEnd bool `json:"at_period_end,omitempty"`
|
|
}
|
|
|
|
func (s *Server) cancelTenant(w http.ResponseWriter, r *http.Request) {
|
|
var in cancelReq
|
|
if r.ContentLength > 0 {
|
|
if !decodeJSON(w, r, &in) {
|
|
return
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), store.TenantUpdate{
|
|
Status: ptrStr("frozen"),
|
|
})
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
s.emitAudit(ctx, r, store.AuditEvent{
|
|
TenantID: t.ID, Action: "tenant.canceled", TargetID: t.ID, TargetType: "tenant",
|
|
Metadata: map[string]interface{}{"reason": in.Reason, "at_period_end": in.AtPeriodEnd},
|
|
})
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
func (s *Server) listTenantProducts(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := r.URL.Query().Get("tenant_id")
|
|
if tenantID == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
list, err := s.Store.ListTenantProducts(ctx, tenantID)
|
|
if err != nil {
|
|
if mapStoreError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": list})
|
|
}
|
|
|
|
// ─── helpers (internal to this file) ──────────────────────────────────────
|
|
|
|
func ptrStr(s string) *string { return &s }
|
|
|
|
func parseDate(p *string) (*time.Time, error) {
|
|
if p == nil || *p == "" {
|
|
return nil, nil
|
|
}
|
|
t, err := time.Parse("2006-01-02", *p)
|
|
if err != nil {
|
|
return nil, errors.New("invalid date")
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// emitAudit is a fire-and-forget audit emit. Failures are logged but not
|
|
// returned to the caller — the actual user-facing operation already succeeded.
|
|
func (s *Server) emitAudit(ctx context.Context, r *http.Request, ev store.AuditEvent) {
|
|
ev.SourceIP = clientIP(r)
|
|
ev.UserAgent = r.UserAgent()
|
|
if _, err := s.Store.AppendAudit(ctx, ev); err != nil {
|
|
s.Log.Warn("audit emit failed", "err", err, "action", ev.Action)
|
|
}
|
|
}
|