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.
306 lines
9.3 KiB
Go
306 lines
9.3 KiB
Go
package provisioning
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"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"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// 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.CreateGrantResult
|
|
}
|
|
|
|
// 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 default workspace
|
|
workspace, err := orgQ.CreateWorkspace(ctx, organization.CreateWorkspaceParams{
|
|
OrgID: org.OrgID,
|
|
Name: "Default",
|
|
Slug: "default",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create workspace: %w", err)
|
|
}
|
|
|
|
// 7. 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)
|
|
}
|
|
|
|
// 8. Create default resource pool for the organization
|
|
entQ := entitlements.New(tx)
|
|
pool, err := entQ.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
|
|
OrgID: org.OrgID,
|
|
Name: "Default",
|
|
Slug: "default",
|
|
PoolType: "default",
|
|
IsAutoManaged: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create resource pool: %w", err)
|
|
}
|
|
|
|
// 9. Create primary pool assignment linking workspace to pool
|
|
poolAssignment, err := entQ.CreatePoolAssignment(ctx, entitlements.CreatePoolAssignmentParams{
|
|
PoolID: pool.PoolID,
|
|
WorkspaceID: workspace.WorkspaceID,
|
|
IsPrimary: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create pool assignment: %w", err)
|
|
}
|
|
|
|
// 10. 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)
|
|
}
|
|
|
|
// 11. Check org type for default plan ladder policy
|
|
var defaultGrant *entitlements.CreateGrantResult
|
|
orgTypeConfig, err := orgQ.GetOrgType(ctx, "personal")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get org type config: %w", err)
|
|
}
|
|
|
|
if orgTypeConfig.DefaultPlanLadderID.Valid {
|
|
// 12. Resolve the rank-0 tier of the configured ladder to get the
|
|
// default product, then create the grant + ladder attachment +
|
|
// initiate transition so the pool starts on the correct tier.
|
|
ladderID := orgTypeConfig.DefaultPlanLadderID.UUID.String()
|
|
bq := billing.New(tx)
|
|
rankZero, err := bq.GetTierByLadderRank(ctx, billing.GetTierByLadderRankParams{
|
|
PlanLadderID: ladderID,
|
|
Rank: 0,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve rank-0 tier of default ladder %s: %w", ladderID, err)
|
|
}
|
|
|
|
defaultGrant, err = entitlements.CreateGrantInTx(ctx, entQ, tx, entitlements.CreateGrantInput{
|
|
ProductID: rankZero.ProductID,
|
|
OrgID: org.OrgID,
|
|
GrantReason: "default",
|
|
Quantity: 1,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create default grant: %w", err)
|
|
}
|
|
|
|
if err := attachDefaultGrantToLadder(ctx, tx, pool.PoolID, ladderID, rankZero.ProductID, rankZero.Rank, defaultGrant.Provision.ProvisionID); err != nil {
|
|
return nil, fmt.Errorf("attach default grant to ladder: %w", 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
|
|
}
|
|
|
|
// attachDefaultGrantToLadder creates the pool_provision_ladders row and
|
|
// pool_provision_transitions row for the default-grant-sourced provision at
|
|
// the org type's configured ladder. The caller has already resolved the
|
|
// (ladder, product, rank) triple from the org type's default_plan_ladder_id.
|
|
func attachDefaultGrantToLadder(ctx context.Context, tx *sql.Tx, poolID, ladderID, productID string, rank int32, provisionID string) error {
|
|
entQ := entitlements.New(tx)
|
|
activatedAt := time.Now()
|
|
if _, err := entQ.CreatePoolProvisionLadder(ctx, entitlements.CreatePoolProvisionLadderParams{
|
|
ProvisionID: provisionID,
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
PoolID: poolID,
|
|
Status: "active",
|
|
ActivatedAt: activatedAt,
|
|
}); err != nil {
|
|
return fmt.Errorf("create ladder attachment: %w", err)
|
|
}
|
|
|
|
provUUID, err := uuid.Parse(provisionID)
|
|
if err != nil {
|
|
return fmt.Errorf("parse provision id: %w", err)
|
|
}
|
|
if _, err := entQ.CreatePoolProvisionTransition(ctx, entitlements.CreatePoolProvisionTransitionParams{
|
|
PoolID: poolID,
|
|
ProvisionID: uuid.NullUUID{UUID: provUUID, Valid: true},
|
|
PlanLadderID: ladderID,
|
|
FromRank: sql.NullInt32{},
|
|
ToRank: sql.NullInt32{Int32: rank, Valid: true},
|
|
TransitionType: "initiate",
|
|
ActorType: "system",
|
|
ActorID: uuid.NullUUID{},
|
|
Reason: sql.NullString{String: "auto-provisioning on org creation", Valid: true},
|
|
EffectiveAt: activatedAt,
|
|
}); err != nil {
|
|
return fmt.Errorf("record initiate transition: %w", err)
|
|
}
|
|
return 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(), "-")
|
|
}
|