Files
member-console/internal/server/operator_partials.go
Christian Galo 751bae7768 Use plan ladder for org defaults
Add default_plan_ladder_id with a forward data migration and update
the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate
sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and
tests; add GetTierByLadderRank and pool/provision query helpers. Add a
CSP-safe confirm-action modal and wire operator actions to it. Close
plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE
pool lock to prevent ladder races.
2026-04-27 01:57:17 -05:00

624 lines
23 KiB
Go

package server
import (
"database/sql"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// OperatorRole is the OIDC role required to access operator pages
const OperatorRole = "operator-member"
// OperatorPartialsHandler handles HTMX partial requests for operator pages
type OperatorPartialsHandler struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
StripeQ stripedb.Querier
Database *sql.DB
IdentityQ identity.Querier
OrgQ organization.Querier
Logger *slog.Logger
AuthConfig *auth.Config
Templates *SafeTemplates
StripeDashboardURL string
TemporalClient client.Client
}
// OperatorPartialsConfig holds configuration for the operator partials handler
type OperatorPartialsConfig struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
StripeQ stripedb.Querier
Database *sql.DB
IdentityQ identity.Querier
OrgQ organization.Querier
Logger *slog.Logger
AuthConfig *auth.Config
StripeDashboardURL string
TemporalClient client.Client
}
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
// Parse partial templates
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.ParseFS(templateSubFS, "operator_*.html")
if err != nil {
return nil, err
}
return &OperatorPartialsHandler{
SiteQ: cfg.SiteQ,
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
StripeDashboardURL: cfg.StripeDashboardURL,
TemporalClient: cfg.TemporalClient,
}, nil
}
// RegisterRoutes registers all operator HTMX partial routes
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/operator/users", h.requireOperatorRole(h.GetPersons))
mux.HandleFunc("GET /partials/operator/organizations", h.requireOperatorRole(h.GetOrganizations))
mux.HandleFunc("GET /partials/operator/sites", h.requireOperatorRole(h.GetSites))
// Grant management
mux.HandleFunc("GET /partials/operator/grants", h.requireOperatorRole(h.GetGrants))
mux.HandleFunc("POST /partials/operator/grants", h.requireOperatorRole(h.CreateGrant))
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke", h.requireOperatorRole(h.RevokeGrant))
// Product management
mux.HandleFunc("GET /partials/operator/products", h.requireOperatorRole(h.GetProducts))
mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct))
mux.HandleFunc("GET /partials/operator/products/{productID}/edit", h.requireOperatorRole(h.GetProductEdit))
mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct))
// Entitlement set management
mux.HandleFunc("GET /partials/operator/entitlement-sets", h.requireOperatorRole(h.GetEntitlementSets))
mux.HandleFunc("POST /partials/operator/entitlement-sets", h.requireOperatorRole(h.CreateEntitlementSet))
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/edit", h.requireOperatorRole(h.GetEntitlementSetEdit))
mux.HandleFunc("PUT /partials/operator/entitlement-sets/{setID}", h.requireOperatorRole(h.UpdateEntitlementSet))
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.GetEntitlementSetRules))
mux.HandleFunc("POST /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.CreateEntitlementSetRule))
mux.HandleFunc("DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}", h.requireOperatorRole(h.DeleteEntitlementSetRule))
// Organization type management
mux.HandleFunc("GET /partials/operator/org-types", h.requireOperatorRole(h.GetOrgTypes))
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-plan", h.requireOperatorRole(h.UpdateOrgTypeDefaultPlan))
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/backfill", h.requireOperatorRole(h.BackfillOrgType))
// Enrollment & transition tools
mux.HandleFunc("GET /partials/operator/organizations/{orgID}/enrollment", h.requireOperatorRole(h.GetOrgEnrollment))
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant", h.requireOperatorRole(h.IssueGrant))
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend", h.requireOperatorRole(h.ExtendGrant))
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke-and-transition", h.requireOperatorRole(h.RevokeGrantAndTransition))
// Billing views
mux.HandleFunc("GET /partials/operator/billing/accounts", h.requireOperatorRole(h.GetBillingAccounts))
mux.HandleFunc("GET /partials/operator/billing/subscriptions", h.requireOperatorRole(h.GetSubscriptions))
mux.HandleFunc("GET /partials/operator/billing/invoices", h.requireOperatorRole(h.GetInvoices))
mux.HandleFunc("GET /partials/operator/billing/payments", h.requireOperatorRole(h.GetPayments))
// Product prices
mux.HandleFunc("GET /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.GetProductPrices))
mux.HandleFunc("POST /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.CreatePrice))
// Plan ladder management
mux.HandleFunc("GET /partials/operator/plan-ladders", h.requireOperatorRole(h.GetPlanLadders))
mux.HandleFunc("POST /partials/operator/plan-ladders", h.requireOperatorRole(h.CreatePlanLadder))
mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/edit", h.requireOperatorRole(h.GetPlanLadderEdit))
mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.UpdatePlanLadder))
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.DeletePlanLadder))
mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.GetPlanLadderTiers))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.CreatePlanLadderTier))
mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/rank", h.requireOperatorRole(h.UpdatePlanLadderTierRank))
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}", h.requireOperatorRole(h.DeletePlanLadderTier))
mux.HandleFunc("GET /partials/operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidation))
}
// requireOperatorRole is middleware that checks for the operator role
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.AuthConfig.HasRole(r, OperatorRole) {
h.Logger.Warn("operator partials access denied",
slog.String("path", r.URL.Path),
slog.String("reason", "missing operator-member role"))
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// PersonViewModel represents a person for operator template rendering
type PersonViewModel struct {
PersonID string
DisplayName string
Email string
Status string
CreatedAt string
}
// PersonsData holds data for the persons list partial
type PersonsData struct {
Persons []PersonViewModel
Error string
}
// GetPersons handles GET /partials/operator/users — lists all persons
func (h *OperatorPartialsHandler) GetPersons(w http.ResponseWriter, r *http.Request) {
persons, err := h.IdentityQ.ListPersons(r.Context())
if err != nil {
h.Logger.Error("failed to list persons", slog.Any("error", err))
h.renderError(w, "operator_users.html", "Failed to retrieve people")
return
}
vms := make([]PersonViewModel, len(persons))
for i, p := range persons {
vms[i] = PersonViewModel{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
Email: p.PrimaryEmail,
Status: p.Status,
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
}
}
data := PersonsData{Persons: vms}
h.Templates.Render(w, "operator_users.html", data)
}
// OrganizationViewModel represents an organization for operator template rendering
type OrganizationViewModel struct {
OrgID string
Name string
Slug string
OrgType string
Status string
MemberCount int
CreatedAt string
}
// OrganizationsData holds data for the organizations list partial
type OrganizationsData struct {
Organizations []OrganizationViewModel
Error string
}
// GetOrganizations handles GET /partials/operator/organizations
func (h *OperatorPartialsHandler) GetOrganizations(w http.ResponseWriter, r *http.Request) {
orgs, err := h.OrgQ.ListOrganizations(r.Context())
if err != nil {
h.Logger.Error("failed to list organizations", slog.Any("error", err))
h.renderError(w, "operator_organizations.html", "Failed to retrieve organizations")
return
}
vms := make([]OrganizationViewModel, len(orgs))
for i, org := range orgs {
memberCount := 0
members, err := h.OrgQ.GetOrgMembersByOrgID(r.Context(), org.OrgID)
if err == nil {
memberCount = len(members)
}
vms[i] = OrganizationViewModel{
OrgID: org.OrgID,
Name: org.Name,
Slug: org.Slug,
OrgType: org.OrgType,
Status: org.Status,
MemberCount: memberCount,
CreatedAt: org.CreatedAt.Format("Jan 2, 2006"),
}
}
data := OrganizationsData{Organizations: vms}
h.Templates.Render(w, "operator_organizations.html", data)
}
// OperatorSiteViewModel represents a site for operator template rendering
type OperatorSiteViewModel struct {
ID string
Domain string
OwnerOrgName string
IsCustomDomain bool
CreatedAt string
}
// OperatorSitesData holds data for the sites list partial
type OperatorSitesData struct {
Sites []OperatorSiteViewModel
Error string
}
// GetSites handles GET /partials/operator/sites
func (h *OperatorPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
sites, err := h.SiteQ.ListAllSites(r.Context())
if err != nil {
h.Logger.Error("failed to get all sites", slog.Any("error", err))
h.renderError(w, "operator_sites.html", "Failed to retrieve sites")
return
}
// Build a cache of workspace_id → org name to avoid repeated lookups
orgNameCache := make(map[string]string)
siteVMs := make([]OperatorSiteViewModel, len(sites))
for i, site := range sites {
orgName := ""
if cached, ok := orgNameCache[site.WorkspaceID]; ok {
orgName = cached
} else {
ws, err := h.OrgQ.GetWorkspaceByID(r.Context(), site.WorkspaceID)
if err == nil {
org, err := h.OrgQ.GetOrganizationByID(r.Context(), ws.OrgID)
if err == nil {
orgName = org.Name
}
}
orgNameCache[site.WorkspaceID] = orgName
}
siteVMs[i] = OperatorSiteViewModel{
ID: site.SiteID,
Domain: site.Domain,
OwnerOrgName: orgName,
IsCustomDomain: site.IsCustomDomain,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
}
}
data := OperatorSitesData{Sites: siteVMs}
h.Templates.Render(w, "operator_sites.html", data)
}
// Helper functions
func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) {
data := struct{ Error string }{Error: message}
h.Templates.Render(w, tmplName, data)
}
// GrantViewModel represents a grant for operator template rendering.
type GrantViewModel struct {
GrantID string
OrgName string
ProductName string
EntitlementSetName string
Quantity int32
GrantReason string
Status string
CreatedAt string
}
// GrantsData holds data for the grants management partial.
type GrantsData struct {
Grants []GrantViewModel
Organizations []OrganizationViewModel
Products []ProductViewModel
EntitlementSets []EntitlementSetOption
Success string
Error string
}
// ProductViewModel represents a product for the grant creation form.
type ProductViewModel struct {
ProductID string
Name string
ProductType string
}
// GetGrants handles GET /partials/operator/grants — renders the grant management section.
func (h *OperatorPartialsHandler) GetGrants(w http.ResponseWriter, r *http.Request) {
h.renderGrantsPage(w, r, "", "")
}
// CreateGrant handles POST /partials/operator/grants — creates a grant and re-renders the page.
func (h *OperatorPartialsHandler) CreateGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderGrantsPage(w, r, "", "Unauthorized")
return
}
if err := r.ParseForm(); err != nil {
h.renderGrantsPage(w, r, "", "Invalid request")
return
}
grantPath := r.FormValue("grant_path")
productID := r.FormValue("product_id")
entitlementSetID := r.FormValue("entitlement_set_id")
orgID := r.FormValue("org_id")
reason := r.FormValue("reason")
quantityStr := r.FormValue("quantity")
// Validate: exactly one path must be selected
if grantPath != "product" && grantPath != "entitlement_set" {
h.renderGrantsPage(w, r, "", "Select either Product or Entitlement Set path")
return
}
if grantPath == "product" && productID == "" {
h.renderGrantsPage(w, r, "", "Product is required for product path")
return
}
if grantPath == "entitlement_set" && entitlementSetID == "" {
h.renderGrantsPage(w, r, "", "Entitlement set is required for entitlement set path")
return
}
if orgID == "" {
h.renderGrantsPage(w, r, "", "Organization is required")
return
}
quantity := int32(1)
if quantityStr != "" {
if q, err := strconv.Atoi(quantityStr); err == nil && q > 0 {
quantity = int32(q)
}
}
// Safety guard: plan-tier products must go through the Enrollment page
// so that Transition() is invoked and ladder attachments are created.
if grantPath == "product" && productID != "" {
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
if err == nil && len(ladders) > 0 {
h.renderGrantsPage(w, r, "", "Plan products are granted through the organization's Enrollment tab; the general Grants tab is for addons and one-time products only.")
return
}
}
input := entitlements.CreateGrantInput{
OrgID: orgID,
GrantedByPersonID: session.PersonID,
GrantReason: reason,
Quantity: quantity,
}
if grantPath == "product" {
input.ProductID = productID
} else {
input.EntitlementSetID = entitlementSetID
}
_, err := entitlements.CreateGrantAndMaterialize(r.Context(), h.Database, input)
if err != nil {
h.Logger.Error("failed to create grant", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to create grant: "+err.Error())
return
}
h.renderGrantsPage(w, r, "Grant created successfully.", "")
}
// RevokeGrant handles POST /partials/operator/grants/{grantID}/revoke — revokes a grant and re-renders.
func (h *OperatorPartialsHandler) RevokeGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderGrantsPage(w, r, "", "Unauthorized")
return
}
grantID := r.PathValue("grantID")
if grantID == "" {
h.renderGrantsPage(w, r, "", "Grant ID is required")
return
}
grantUUID, err := uuid.Parse(grantID)
if err != nil {
h.renderGrantsPage(w, r, "", "Invalid grant ID")
return
}
// Pre-tx peek to find the provision's pool, so we know which row to lock.
// State may change between this read and the lock — we re-resolve the
// ladder attachment after the lock to make the decision authoritative.
provision, err := h.EntitlementsQ.GetPoolProvisionByGrantID(r.Context(), uuid.NullUUID{UUID: grantUUID, Valid: true})
if err != nil || provision.ProvisionID == "" {
// No provision (revoked grant, or never provisioned). Plain revoke path.
if err := entitlements.RevokeGrantAndRematerialize(r.Context(), h.Database, grantID, session.PersonID, "operator_revocation"); err != nil {
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error())
return
}
h.renderGrantsPage(w, r, "Grant revoked successfully.", "")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Transaction failed")
return
}
defer tx.Rollback()
// Lock ordering: pool → grant → provision → ladder.
if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", provision.PoolID); err != nil {
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Pool lock failed")
return
}
q := entitlements.New(tx)
// Authoritative re-read inside the tx, after the lock.
attachments, err := q.GetActiveAttachmentsByPool(r.Context(), provision.PoolID)
if err != nil {
h.Logger.Error("failed to read ladder attachments", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to read pool state")
return
}
hasLadderAttachment := false
for _, att := range attachments {
if att.ProvisionID == provision.ProvisionID {
hasLadderAttachment = true
break
}
}
if _, err := q.RevokeGrant(r.Context(), entitlements.RevokeGrantParams{
GrantID: grantID,
RevokedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
RevocationReason: sql.NullString{String: "operator_revocation", Valid: true},
}); err != nil {
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error())
return
}
if hasLadderAttachment {
// Transition(End) ends the prior provision itself, captures from_rank,
// and re-applies the org-type default. Do NOT pre-end the provision —
// that erases the from_rank context Transition needs to classify the
// transition as a downgrade vs. initiate.
if _, err := entitlements.Transition(r.Context(), tx, provision.PoolID, entitlements.TransitionTarget{End: true},
entitlements.TransitionActor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: "operator_revocation",
}); err != nil {
h.Logger.Error("transition failed after revocation", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Transition failed: "+err.Error())
return
}
} else {
// No ladder attachment — end the provision and rematerialize so the
// pool reflects the change. No transition row (consistent with
// non-plan revocations).
if _, err := q.UpdatePoolProvisionStatus(r.Context(), entitlements.UpdatePoolProvisionStatusParams{
ProvisionID: provision.ProvisionID,
Status: "ended",
}); err != nil {
h.Logger.Error("failed to end provision", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to end provision: "+err.Error())
return
}
if err := entitlements.MaterializePoolEntitlements(r.Context(), q, provision.PoolID); err != nil {
h.Logger.Error("failed to rematerialize pool", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Failed to rematerialize: "+err.Error())
return
}
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderGrantsPage(w, r, "", "Commit failed")
return
}
h.renderGrantsPage(w, r, "Grant revoked successfully.", "")
}
// renderGrantsPage loads all data needed for the grants management page and renders it.
func (h *OperatorPartialsHandler) renderGrantsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
data := GrantsData{
Success: success,
Error: errMsg,
}
if errMsg == "" {
// Load organizations for the form
orgs, err := h.OrgQ.ListOrganizations(r.Context())
if err != nil {
h.Logger.Error("failed to list organizations", slog.Any("error", err))
data.Error = "Failed to load organizations"
} else {
data.Organizations = make([]OrganizationViewModel, len(orgs))
for i, org := range orgs {
data.Organizations[i] = OrganizationViewModel{
OrgID: org.OrgID,
Name: org.Name,
Slug: org.Slug,
}
}
}
// Load products for the form
products, err := h.BillingQ.ListActiveProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
} else {
data.Products = make([]ProductViewModel, len(products))
for i, p := range products {
data.Products[i] = ProductViewModel{
ProductID: p.ProductID,
Name: p.Name,
ProductType: p.ProductType.String,
}
}
}
// Load active entitlement sets for the form
data.EntitlementSets = h.loadEntitlementSetOptions(r)
// Load all grants
grants, err := h.EntitlementsQ.ListAllGrants(r.Context())
if err != nil {
h.Logger.Error("failed to list grants", slog.Any("error", err))
} else {
data.Grants = make([]GrantViewModel, len(grants))
for i, g := range grants {
// Look up org name
orgName := ""
if g.GrantedToOrgID.Valid {
org, err := h.OrgQ.GetOrganizationByID(r.Context(), g.GrantedToOrgID.UUID.String())
if err == nil {
orgName = org.Name
}
}
// Look up product name
productName := ""
if g.ProductID.Valid {
if p, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID.UUID.String()); err == nil {
productName = p.Name
}
}
// Look up entitlement set name (for direct entitlement set grants)
entitlementSetName := ""
if !g.ProductID.Valid && g.EntitlementSetID.Valid {
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), g.EntitlementSetID.UUID.String()); err == nil {
entitlementSetName = es.Name
}
}
data.Grants[i] = GrantViewModel{
GrantID: g.GrantID,
OrgName: orgName,
ProductName: productName,
EntitlementSetName: entitlementSetName,
Quantity: g.Quantity,
GrantReason: g.GrantReason,
Status: g.Status,
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
}
}
}
}
h.Templates.Render(w, "operator_grants.html", data)
}