Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
234 lines
7.9 KiB
Go
234 lines
7.9 KiB
Go
package provisioning
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"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 := DisplayNameFromClaims(claims.Name, 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
|
|
// The name is derived from the display name; nothing is derived from the
|
|
// username. Names are not unique, so there is no collision handling here.
|
|
// The organization gets no key: a key is never derived from a person's
|
|
// login or display name (entity-keys §4), and nothing outside the
|
|
// database addresses a personal organization literally -- it is reached
|
|
// through its owner, whose own key is the OIDC subject her IdP issues.
|
|
orgName := personalOrgName(displayName)
|
|
|
|
org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
|
|
Name: orgName,
|
|
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")
|
|
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"
|
|
}
|
|
|
|
// DisplayNameFromClaims picks the display name an IdP's claims support: the
|
|
// `name` claim, else `preferred_username`, else "" (callers keep whatever
|
|
// they already hold when both are empty). First-login provisioning and the
|
|
// returning-login resync in internal/auth use the same rule, so a login can
|
|
// never downgrade a display name to an empty string.
|
|
func DisplayNameFromClaims(name, preferredUsername string) string {
|
|
if n := strings.TrimSpace(name); n != "" {
|
|
return n
|
|
}
|
|
return strings.TrimSpace(preferredUsername)
|
|
}
|