Files
tenant-registry/internal/server/tenants.go
T
Sharang ParnerkarandClaude Fable 5 576d733eae
ci / shared (pull_request) Successful in 14s
ci / test (pull_request) Failing after 16m9s
ci / image (pull_request) Skipped
feat(tenants): provision the product tenant with the registry UUID on create
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
2026-09-01 23:16:33 +02:00

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)
}
}