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) }