Files
member-console/internal/provisioning/provisioning.go
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

263 lines
8.2 KiB
Go

package provisioning
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"unicode"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// OIDCClaims holds the claims extracted from OIDC authentication.
type OIDCClaims struct {
Subject string
Email string
EmailVerified bool
Name string
PreferredUsername string
}
// Result holds all records created during auto-provisioning.
type Result struct {
User identity.User
Person identity.Person
Org organization.Organization
OrgMember organization.OrgMember
Workspace organization.Workspace
Pool entitlements.ResourcePool
PoolAssignment entitlements.PoolAssignment
BillingAccount billing.Account
// DefaultGrant is populated only when the org type has a default product configured.
DefaultGrant *entitlements.ConferGrantResult
}
// AutoProvision creates all governance structures for a new user within a
// single database transaction: user → person → org → org_member → workspace.
func AutoProvision(ctx context.Context, db *sql.DB, claims OIDCClaims) (*Result, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
idQ := identity.New(tx)
orgQ := organization.New(tx)
// 1. Create user
user, err := idQ.CreateUser(ctx, claims.Subject)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
// 2. Create person
displayName := claims.Name
if displayName == "" {
displayName = claims.PreferredUsername
}
person, err := idQ.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: displayName,
PrimaryEmail: claims.Email,
PrimaryEmailVerified: claims.EmailVerified,
})
if err != nil {
return nil, fmt.Errorf("create person: %w", err)
}
// 3. Create personal organization
orgName := personalOrgName(displayName)
slug, err := resolveSlug(ctx, orgQ, claims.PreferredUsername)
if err != nil {
return nil, fmt.Errorf("resolve org slug: %w", err)
}
org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: orgName,
Slug: slug,
OrgType: "personal",
OwnerPersonID: person.PersonID,
})
if err != nil {
return nil, fmt.Errorf("create organization: %w", err)
}
// 4. Look up the owner system role
ownerRole, err := orgQ.GetSystemRoleByName(ctx, "owner")
if err != nil {
return nil, fmt.Errorf("get owner role: %w", err)
}
// 5. Create org membership
orgMember, err := orgQ.CreateOrgMember(ctx, organization.CreateOrgMemberParams{
OrgID: org.OrgID,
PersonID: person.PersonID,
RoleID: ownerRole.RoleID,
})
if err != nil {
return nil, fmt.Errorf("create org member: %w", err)
}
// 6. Create org-scoped role assignment for the owner
_, err = orgQ.CreateRoleAssignment(ctx, organization.CreateRoleAssignmentParams{
RoleID: ownerRole.RoleID,
PersonID: person.PersonID,
OrgID: org.OrgID,
ScopeType: "organization",
ScopeID: org.OrgID,
})
if err != nil {
return nil, fmt.Errorf("create role assignment: %w", err)
}
// 7. Create the default workspace and its primary assignment to the
// org's default resource pool (created here, since a brand-new org has
// none yet) in one shared step -- the same one the member "create
// workspace" handler uses, so this rule has exactly one implementation
// (schema-hardening design D2).
wsResult, err := CreateWorkspaceWithPrimaryAssignment(ctx, tx, org.OrgID, "Default", "default")
if err != nil {
return nil, fmt.Errorf("create workspace: %w", err)
}
workspace := wsResult.Workspace
pool := wsResult.Pool
poolAssignment := wsResult.PoolAssignment
// 8. Create default billing account for the organization
billQ := billing.New(tx)
billingAccount, err := billQ.CreateBillingAccount(ctx, billing.CreateBillingAccountParams{
OrgID: org.OrgID,
Name: "Default",
Status: "active",
Metadata: json.RawMessage("{}"),
})
if err != nil {
return nil, fmt.Errorf("create billing account: %w", err)
}
// 9. Check org type for default plan ladder policy
var defaultGrant *entitlements.ConferGrantResult
orgTypeConfig, err := orgQ.GetOrgType(ctx, "personal")
if err != nil {
return nil, fmt.Errorf("get org type config: %w", err)
}
if orgTypeConfig.DefaultPlanLadderID.Valid {
// 10. Resolve the rank-0 tier of the configured ladder to get the
// default product, then confer the grant so the pool starts on the
// correct tier. Conferral writes the position and transition rows.
//
// A missing rank-0 tier (out-of-band damage: e.g. the ladder's last
// tier was deleted outside the app-level guard) must not fail the
// signup with a 500 -- every other governance structure above is
// already valid and worth keeping. Skip conferral, complete
// provisioning plan-less, and log loudly: the org is repairable
// later via ReapplyDefaultsIfVacant once the ladder is fixed
// (schema-hardening design D4b).
ladderID := orgTypeConfig.DefaultPlanLadderID.UUID.String()
bq := billing.New(tx)
rankZero, err := bq.GetTierByLadderRank(ctx, billing.GetTierByLadderRankParams{
PlanLadderID: ladderID,
Rank: 0,
})
switch {
case err == nil:
// GrantedByPersonID stays "" (system-authored) as required for grant_reason "default".
defaultGrant, err = entitlements.ConferGrantTx(ctx, tx, entitlements.ConferGrantInput{
ProductID: rankZero.ProductID,
OrgID: org.OrgID,
GrantReason: "default",
Quantity: 1,
ActorType: "system",
TransitionReason: "auto-provisioning on org creation",
})
if err != nil {
return nil, fmt.Errorf("confer default grant: %w", err)
}
case errors.Is(err, sql.ErrNoRows):
slog.Default().Error(
"AUTO-PROVISIONING ALARM: default plan ladder has no rank-0 tier -- signup completed WITHOUT a plan",
slog.String("org_type", orgTypeConfig.OrgType),
slog.String("plan_ladder_id", ladderID),
slog.String("org_id", org.OrgID),
slog.String("user_id", user.UserID),
)
default:
return nil, fmt.Errorf("resolve rank-0 tier of default ladder %s: %w", ladderID, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
return &Result{
User: user,
Person: person,
Org: org,
OrgMember: orgMember,
Workspace: workspace,
Pool: pool,
PoolAssignment: poolAssignment,
BillingAccount: billingAccount,
DefaultGrant: defaultGrant,
}, nil
}
// personalOrgName derives a personal organization name from a display name.
// e.g., "Carlos" → "Carlos's Organization"
func personalOrgName(displayName string) string {
if displayName == "" {
return "Personal Organization"
}
if strings.HasSuffix(displayName, "s") || strings.HasSuffix(displayName, "S") {
return displayName + "' Organization"
}
return displayName + "'s Organization"
}
// resolveSlug derives a URL-safe slug from a username, appending a numeric
// suffix if the slug conflicts with an existing organization.
func resolveSlug(ctx context.Context, q *organization.Queries, username string) (string, error) {
base := slugify(username)
if base == "" {
base = "org"
}
// Try the base slug first
slug := base
for attempt := 2; attempt <= 100; attempt++ {
_, err := q.GetOrganizationBySlug(ctx, slug)
if err == sql.ErrNoRows {
return slug, nil // Available
}
if err != nil {
return "", err // Unexpected error
}
// Conflict — try next suffix
slug = fmt.Sprintf("%s-%d", base, attempt)
}
return "", fmt.Errorf("unable to find available slug for %q after 100 attempts", username)
}
// slugify converts a string to a URL-safe slug.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
} else if r == '-' || r == '_' || r == '.' {
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}