Files
member-console/internal/systemtenant/systemtenant.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
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.
2026-08-29 20:12:04 -05:00

137 lines
5.1 KiB
Go

// Package systemtenant ensures the singleton "System" tenant exists — a
// platform-owned organization (org_type='system') and its workspace, owned by a
// reserved synthetic person — and resolves that workspace by natural key.
//
// The System tenant is the owning workspace for provider resources that belong
// to no member (e.g. ownerless FedWiki farm sites projected by the sync). It is
// identified by what it IS (org_type='system'), not by an opaque UUID copied
// into configuration, so it cannot drift out of sync the way the former
// fedwiki-sync-default-workspace-id config key could.
package systemtenant
import (
"context"
"database/sql"
"errors"
"fmt"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// Reserved natural-key identity of the System tenant. These values are stable
// by contract: renaming one would orphan an existing tenant rather than rename
// it, so change them only with a deliberate migration.
const (
// OrgType is the reserved organization type. Exported because it is the
// natural key callers resolve the tenant by.
OrgType = "system"
orgName = "System"
workspaceName = "System"
// userOIDCSubject is the reserved synthetic OIDC subject for the system
// user. The "urn:" scheme guarantees it never collides with a real IdP
// subject (Keycloak issues UUID subjects), and there is no matching IdP
// account, so the system user is non-loginable.
userOIDCSubject = "urn:member-console:system"
personName = "System"
// personEmail uses the RFC 2606 reserved ".invalid" TLD, so it is
// guaranteed non-routable.
personEmail = "system@member-console.invalid"
)
// Ensure idempotently guarantees the System tenant exists and returns its
// workspace ID. It is meant to run at boot, after migrations and before the
// FedWiki sync schedule is wired. It is safe to call on every boot and under
// concurrent boots (each insert is ON CONFLICT DO NOTHING). Any returned error
// should be treated by the caller as fatal: the sync's landing workspace must
// exist.
func Ensure(ctx context.Context, db *sql.DB) (workspaceID string, err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("system tenant: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }() // no-op after a successful commit
idQ := identity.New(tx)
orgQ := organization.New(tx)
// Fast path / adoption: if the system org already has a workspace, adopt
// it. This is the steady-state no-op and also adopts a pre-existing
// differently-named workspace (e.g. one left by an ad-hoc seed) without
// creating a second one.
if ws, gErr := orgQ.GetSystemOrgWorkspace(ctx); gErr == nil {
if err = tx.Commit(); err != nil {
return "", fmt.Errorf("system tenant: commit (adopt): %w", err)
}
return ws.WorkspaceID, nil
} else if !errors.Is(gErr, sql.ErrNoRows) {
return "", fmt.Errorf("system tenant: resolve workspace: %w", gErr)
}
// Create the chain: user -> person -> org_type -> org -> workspace.
if err = idQ.EnsureSystemUser(ctx, userOIDCSubject); err != nil {
return "", fmt.Errorf("system tenant: ensure user: %w", err)
}
user, err := idQ.GetUserByOIDCSubject(ctx, userOIDCSubject)
if err != nil {
return "", fmt.Errorf("system tenant: get user: %w", err)
}
if err = idQ.EnsureSystemPerson(ctx, identity.EnsureSystemPersonParams{
UserID: user.UserID,
DisplayName: personName,
PrimaryEmail: personEmail,
}); err != nil {
return "", fmt.Errorf("system tenant: ensure person: %w", err)
}
person, err := idQ.GetPersonByUserID(ctx, user.UserID)
if err != nil {
return "", fmt.Errorf("system tenant: get person: %w", err)
}
if err = orgQ.EnsureSystemOrgType(ctx, organization.EnsureSystemOrgTypeParams{
OrgType: OrgType,
DisplayName: orgName,
}); err != nil {
return "", fmt.Errorf("system tenant: ensure org type: %w", err)
}
// The partial unique index uq_organizations_one_system carries "exactly
// one System tenant", so the ensure conflicts on org_type and the
// read-back needs no other handle. EnsureSystemOrganization also writes
// the organization's key, the constant `system` (entity-keys §4); it is
// set in the query rather than passed from here so the only path that
// creates the System tenant is also the only path that names it.
if err = orgQ.EnsureSystemOrganization(ctx, organization.EnsureSystemOrganizationParams{
Name: orgName,
OrgType: OrgType,
OwnerPersonID: person.PersonID,
}); err != nil {
return "", fmt.Errorf("system tenant: ensure org: %w", err)
}
org, err := orgQ.GetSystemOrganization(ctx)
if err != nil {
return "", fmt.Errorf("system tenant: get org: %w", err)
}
if err = orgQ.EnsureSystemWorkspace(ctx, organization.EnsureSystemWorkspaceParams{
OrgID: org.OrgID,
Name: workspaceName,
}); err != nil {
return "", fmt.Errorf("system tenant: ensure workspace: %w", err)
}
ws, err := orgQ.GetSystemOrgWorkspace(ctx)
if err != nil {
return "", fmt.Errorf("system tenant: resolve workspace after create: %w", err)
}
if err = tx.Commit(); err != nil {
return "", fmt.Errorf("system tenant: commit: %w", err)
}
return ws.WorkspaceID, nil
}