Files
member-console/internal/server/operator_enrollment.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

613 lines
20 KiB
Go

package server
import (
"database/sql"
"log/slog"
"net/http"
"strconv"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
wf "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// PoolEnrollmentViewModel represents a pool's current enrollment state.
type PoolEnrollmentViewModel struct {
PoolID string
PoolName string
PoolType string
LadderKey string
TierName string
ProductID string
Rank int32
ActivatedAt string
HasAttachment bool
}
// TransitionHistoryViewModel represents a single transition row for display.
type TransitionHistoryViewModel struct {
TransitionID string
TransitionType string
FromRank string
ToRank string
ActorType string
ActorName string
Reason string
EffectiveAt string
}
// TierOption represents a selectable tier for force-transition.
type TierOption struct {
LadderID string
LadderKey string
ProductID string
ProductName string
Rank int32
}
// TrialProductOption represents a plan product available for trial grants.
type TrialProductOption struct {
ProductID string
Name string
LadderID string
Rank int32
}
// OrgEnrollmentData holds data for the enrollment detail partial.
type OrgEnrollmentData struct {
OrgID string
OrgName string
OrgSlug string
Pools []PoolEnrollmentViewModel
Transitions []TransitionHistoryViewModel
GrantProducts []TrialProductOption
ActiveGrants []GrantViewModel
Success string
Error string
}
// GetOrgEnrollment handles GET /partials/operator/organizations/{orgID}/enrollment
func (h *OperatorPartialsHandler) GetOrgEnrollment(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("orgID")
h.renderOrgEnrollmentPage(w, r, orgID, "", "")
}
// IssueGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant
func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized")
return
}
orgID := r.PathValue("orgID")
poolID := r.PathValue("poolID")
if err := r.ParseForm(); err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
return
}
productID := r.FormValue("product_id")
validUntilStr := r.FormValue("valid_until")
reason := r.FormValue("reason")
if productID == "" || reason == "" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product and reason are required")
return
}
var validUntil sql.NullTime
grantReason := "manual"
if validUntilStr != "" {
vt, err := time.Parse("2006-01-02T15:04", validUntilStr)
if err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid date format")
return
}
validUntil = sql.NullTime{Time: vt, Valid: true}
grantReason = "trial"
}
// Resolve ladder from product (eliminates need for client-side hidden field)
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
if err != nil || len(ladders) == 0 {
h.Logger.Error("failed to resolve ladder for product", slog.Any("error", err), slog.String("product_id", productID))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Selected product is not attached to any ladder")
return
}
ladderID := ladders[0].PlanLadderID
// Resolve target tier
tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{
PlanLadderID: ladderID,
ProductID: productID,
})
if err != nil {
h.Logger.Error("failed to resolve target tier", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid target tier")
return
}
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
if err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product not found")
return
}
if !product.EntitlementSetID.Valid {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product has no entitlement set")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
return
}
defer tx.Rollback()
// Row-level locking: serialize concurrent grant issuance on the same pool
// to prevent race conditions in ladder state resolution.
_, err = tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID)
if err != nil {
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
return
}
grant, err := entitlements.New(tx).CreateGrant(r.Context(), entitlements.CreateGrantParams{
ProductID: uuid.NullUUID{UUID: uuid.MustParse(productID), Valid: true},
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(orgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
GrantReason: grantReason,
Quantity: 1,
ValidUntil: validUntil,
})
if err != nil {
h.Logger.Error("failed to create grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to create grant: "+err.Error())
return
}
actor := entitlements.TransitionActor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: reason,
}
_, err = entitlements.Transition(r.Context(), tx, poolID, entitlements.TransitionTarget{
LadderID: tier.PlanLadderID,
ProductID: productID,
Source: entitlements.TransitionSource{
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
EntitlementSetID: product.EntitlementSetID.UUID.String(),
Quantity: 1,
},
}, actor)
if err != nil {
h.Logger.Error("transition failed", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
return
}
// Wire Temporal schedule for trial grant expiration
if grantReason == "trial" && h.TemporalClient != nil && validUntil.Valid {
workflowOptions := client.StartWorkflowOptions{
ID: "grant-expiration-" + grant.GrantID,
TaskQueue: queues.Main,
}
_, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
GrantID: grant.GrantID,
ValidUntil: validUntil.Time,
})
if err != nil {
h.Logger.Warn("failed to schedule grant expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID))
}
}
msg := "Grant issued successfully."
if grantReason == "trial" {
msg = "Trial grant issued successfully."
}
h.renderOrgEnrollmentPage(w, r, orgID, msg, "")
}
// ExtendGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend
// — issues a new grant on the pool's current tier and records a transition_type='extend' row.
func (h *OperatorPartialsHandler) ExtendGrant(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized")
return
}
orgID := r.PathValue("orgID")
poolID := r.PathValue("poolID")
if err := r.ParseForm(); err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
return
}
reason := r.FormValue("reason")
if reason == "" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Reason is required")
return
}
// Optional valid_until: when set, the new grant expires at that time and a
// Temporal expiration workflow is scheduled. When omitted, the extension
// is open-ended (comp/gift use case).
var validUntil sql.NullTime
if vu := r.FormValue("valid_until"); vu != "" {
t, err := time.Parse("2006-01-02T15:04", vu)
if err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid date format")
return
}
validUntil = sql.NullTime{Time: t, Valid: true}
}
// Resolve the pool's current active attachment to determine target tier.
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), poolID)
if err != nil || len(attachments) == 0 {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool has no active plan tier to extend")
return
}
att := attachments[0]
product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID)
if err != nil {
h.Logger.Error("failed to resolve product for attachment", slog.Any("error", err), slog.String("product_id", att.ProductID))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product not found")
return
}
if !product.EntitlementSetID.Valid {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product has no entitlement set")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
return
}
defer tx.Rollback()
// Row-level locking: serialize concurrent extend operations on the same pool.
_, err = tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID)
if err != nil {
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
return
}
// Re-verify attachment still exists after lock (could have been ended by concurrent request).
postLockAttachments, err := entitlements.New(tx).GetActiveAttachmentsByPool(r.Context(), poolID)
if err != nil || len(postLockAttachments) == 0 {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool tier changed during request; please refresh")
return
}
if postLockAttachments[0].ProductID != att.ProductID {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool tier changed during request; please refresh")
return
}
// Find the prior grant so we can chain the new one.
provision, err := entitlements.New(tx).GetPoolProvisionByProvisionID(r.Context(), postLockAttachments[0].ProvisionID)
if err != nil {
h.Logger.Error("failed to resolve provision for attachment", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Provision not found")
return
}
grantReason := "manual"
if validUntil.Valid {
grantReason = "trial"
}
grant, err := entitlements.New(tx).CreateGrant(r.Context(), entitlements.CreateGrantParams{
ProductID: uuid.NullUUID{UUID: uuid.MustParse(att.ProductID), Valid: true},
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(orgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
GrantReason: grantReason,
Quantity: 1,
ValidUntil: validUntil,
ExtendsGrantID: provision.GrantID,
})
if err != nil {
h.Logger.Error("failed to create grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to create grant: "+err.Error())
return
}
actor := entitlements.TransitionActor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: reason,
}
_, err = entitlements.Transition(r.Context(), tx, poolID, entitlements.TransitionTarget{
Extend: true,
LadderID: att.PlanLadderID,
ProductID: att.ProductID,
Source: entitlements.TransitionSource{
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
EntitlementSetID: product.EntitlementSetID.UUID.String(),
Quantity: 1,
},
}, actor)
if err != nil {
h.Logger.Error("transition extend failed", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
return
}
// Schedule expiration workflow when the extension has a deadline.
if validUntil.Valid && h.TemporalClient != nil {
workflowOptions := client.StartWorkflowOptions{
ID: "grant-expiration-" + grant.GrantID,
TaskQueue: queues.Main,
}
if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
GrantID: grant.GrantID,
ValidUntil: validUntil.Time,
}); err != nil {
h.Logger.Warn("failed to schedule extension expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID))
}
}
msg := "Tier extended successfully."
if validUntil.Valid {
msg = "Tier extended; new expiration scheduled."
}
h.renderOrgEnrollmentPage(w, r, orgID, msg, "")
}
// RevokeGrantAndTransition handles POST /partials/operator/grants/{grantID}/revoke-and-transition
func (h *OperatorPartialsHandler) RevokeGrantAndTransition(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderOrgEnrollmentPage(w, r, r.FormValue("org_id"), "", "Unauthorized")
return
}
grantID := r.PathValue("grantID")
orgID := r.FormValue("org_id")
if orgID == "" {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Organization ID is required")
return
}
// Pre-tx peek to find the provision's pool. State may change between this
// read and the lock — we re-resolve the ladder attachment after the lock
// to make the decision authoritative.
grantUUID, err := uuid.Parse(grantID)
if err != nil {
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid grant ID")
return
}
provision, err := h.EntitlementsQ.GetPoolProvisionByGrantID(r.Context(), uuid.NullUUID{UUID: grantUUID, Valid: true})
if err != nil || provision.ProvisionID == "" {
h.Logger.Error("failed to get pool provision for grant", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Provision not found")
return
}
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "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.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
return
}
q := entitlements.New(tx)
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.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to revoke grant: "+err.Error())
return
}
// 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.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
return
}
h.renderOrgEnrollmentPage(w, r, orgID, "Grant revoked and transition completed.", "")
}
func (h *OperatorPartialsHandler) renderOrgEnrollmentPage(w http.ResponseWriter, r *http.Request, orgID string, success string, errMsg string) {
data := OrgEnrollmentData{
OrgID: orgID,
Success: success,
Error: errMsg,
}
org, err := h.OrgQ.GetOrganizationByID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to get organization", slog.Any("error", err), slog.String("org_id", orgID))
data.Error = "Organization not found"
h.Templates.Render(w, "operator_enrollment.html", data)
return
}
data.OrgName = org.Name
data.OrgSlug = org.Slug
// Load pools for the org
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID)
if err != nil {
h.Logger.Error("failed to list pools", slog.Any("error", err), slog.String("org_id", orgID))
data.Error = "Failed to load pools"
}
poolVMs := make([]PoolEnrollmentViewModel, len(pools))
for i, pool := range pools {
vm := PoolEnrollmentViewModel{
PoolID: pool.PoolID,
PoolName: pool.Name,
PoolType: pool.PoolType,
}
// Get active attachment for this pool
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), pool.PoolID)
if err == nil && len(attachments) > 0 {
att := attachments[0]
vm.HasAttachment = true
vm.ProductID = att.ProductID
vm.ActivatedAt = att.ActivatedAt.Format("Jan 2, 2006 3:04 PM")
// Resolve ladder and tier name
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), att.PlanLadderID); err == nil {
vm.LadderKey = ladder.LadderKey
}
if product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID); err == nil {
vm.TierName = product.Name
}
if tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{
PlanLadderID: att.PlanLadderID,
ProductID: att.ProductID,
}); err == nil {
vm.Rank = tier.Rank
}
}
poolVMs[i] = vm
}
data.Pools = poolVMs
// Load transitions for all pools
for _, pool := range pools {
transitions, err := h.EntitlementsQ.ListTransitionsByPool(r.Context(), pool.PoolID)
if err != nil {
continue
}
for _, trn := range transitions {
actorName := ""
if trn.ActorID.Valid {
if person, err := h.IdentityQ.GetPersonByID(r.Context(), trn.ActorID.UUID.String()); err == nil {
actorName = person.DisplayName
}
}
fromRank := "—"
if trn.FromRank.Valid {
fromRank = strconv.Itoa(int(trn.FromRank.Int32))
}
toRank := "—"
if trn.ToRank.Valid {
toRank = strconv.Itoa(int(trn.ToRank.Int32))
}
reason := ""
if trn.Reason.Valid {
reason = trn.Reason.String
}
data.Transitions = append(data.Transitions, TransitionHistoryViewModel{
TransitionID: trn.TransitionID,
TransitionType: trn.TransitionType,
FromRank: fromRank,
ToRank: toRank,
ActorType: trn.ActorType,
ActorName: actorName,
Reason: reason,
EffectiveAt: trn.EffectiveAt.Format("Jan 2, 2006 3:04 PM"),
})
}
}
// Load published plan products available for grant issuance
products, err := h.BillingQ.ListActiveProducts(r.Context())
if err == nil {
for _, p := range products {
if p.ProductType.Valid && p.ProductType.String != "" {
continue // skip non-plan products
}
if p.LifecycleStatus != "published" {
continue
}
// Resolve ladder/rank for this product
if tiers, err := h.BillingQ.ListLaddersByProduct(r.Context(), p.ProductID); err == nil && len(tiers) > 0 {
data.GrantProducts = append(data.GrantProducts, TrialProductOption{
ProductID: p.ProductID,
Name: p.Name,
LadderID: tiers[0].PlanLadderID,
Rank: tiers[0].Rank,
})
}
}
}
// Load active grants for the org for revocation
orgUUID, err := uuid.Parse(orgID)
if err == nil {
grants, err := h.EntitlementsQ.ListGrantsByOrgID(r.Context(), uuid.NullUUID{UUID: orgUUID, Valid: true})
if err == nil {
for _, g := range grants {
if g.Status != "active" {
continue
}
productName := ""
if g.ProductID.Valid {
if p, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID.UUID.String()); err == nil {
productName = p.Name
}
}
data.ActiveGrants = append(data.ActiveGrants, GrantViewModel{
GrantID: g.GrantID,
ProductName: productName,
GrantReason: g.GrantReason,
Quantity: g.Quantity,
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
})
}
}
}
h.Templates.Render(w, "operator_enrollment.html", data)
}