package server import ( "context" "errors" "net/http" "regexp" "time" "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"}, }) } 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) } }