481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
10 KiB
Go
284 lines
10 KiB
Go
package org
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// Errors surfaced to callers.
|
|
var (
|
|
// ErrTenancyRequiresAuth reports the one configuration that cannot work: a
|
|
// tenant boundary with no identity behind it.
|
|
ErrTenancyRequiresAuth = errors.New(
|
|
"features.multiTenancy requires features.auth: organizations need authenticated users to belong to them")
|
|
ErrOrgSuspended = errors.New("organization is suspended")
|
|
ErrSlugMismatch = errors.New("the typed slug does not match the organization")
|
|
ErrCannotDeleteLast = errors.New("the last organization cannot be deleted")
|
|
ErrCannotDeleteActive = errors.New("stop the organization's running work before deleting it")
|
|
ErrNameRequired = errors.New("organization name is required")
|
|
)
|
|
|
|
// DataMigrator assigns pre-tenancy rows to the default organization. The task
|
|
// repository implements it; the org package stays free of a task dependency.
|
|
type DataMigrator interface {
|
|
// AssignWorkspacesWithoutOrg returns how many workspaces it moved.
|
|
AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error)
|
|
// DropCrossOrgWorkspaceMembers removes membership rows whose member and
|
|
// workspace would land in different organizations, and returns how many.
|
|
DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error)
|
|
// DeleteOrgData removes every row belonging to an organization.
|
|
DeleteOrgData(ctx context.Context, orgID string) error
|
|
}
|
|
|
|
// AccountMigrator is the user-store half of the same migration.
|
|
type AccountMigrator interface {
|
|
AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error)
|
|
SetOperator(ctx context.Context, id string, operator bool) error
|
|
CountOperators(ctx context.Context) (int, error)
|
|
FirstAdminID(ctx context.Context) (string, error)
|
|
DeleteUsersByOrg(ctx context.Context, orgID string) error
|
|
}
|
|
|
|
// UnitDeleter removes an organization's unit tree.
|
|
//
|
|
// Deletion is the one path where the tree cannot be inferred from anything
|
|
// else: workspaces and accounts are removed by their own owners, and units
|
|
// would otherwise be left behind pointing at an organization that is gone.
|
|
type UnitDeleter interface {
|
|
DeleteOrgUnits(ctx context.Context, orgID string) error
|
|
}
|
|
|
|
// FirstAdminCreator provisions an organization's first administrator. An
|
|
// ordinary admin can only create accounts in their own tenant, so a brand-new
|
|
// organization would otherwise have no way to get its first user; this is the
|
|
// operator-only path that breaks that circularity.
|
|
type FirstAdminCreator func(ctx context.Context, orgID, email, password, displayName string) error
|
|
|
|
// Service owns organization lifecycle and the tenancy migration.
|
|
type Service struct {
|
|
store *Store
|
|
accounts AccountMigrator
|
|
data DataMigrator
|
|
log *logger.Logger
|
|
enabled bool
|
|
firstAdmin FirstAdminCreator
|
|
units UnitDeleter
|
|
}
|
|
|
|
// SetUnitDeleter wires the tree-deletion seam. It is optional only because the
|
|
// unit service is constructed after this one; when it is absent an
|
|
// organization delete leaves its tree behind, which the wiring test guards.
|
|
func (s *Service) SetUnitDeleter(d UnitDeleter) { s.units = d }
|
|
|
|
// SetFirstAdminCreator installs the operator-only first-admin path.
|
|
func (s *Service) SetFirstAdminCreator(create FirstAdminCreator) { s.firstAdmin = create }
|
|
|
|
// CreateFirstAdmin provisions the first administrator of an organization.
|
|
func (s *Service) CreateFirstAdmin(ctx context.Context, orgID, email, password, displayName string) error {
|
|
if s.firstAdmin == nil {
|
|
return errors.New("account provisioning is unavailable")
|
|
}
|
|
if _, err := s.store.Get(ctx, orgID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.firstAdmin(ctx, orgID, email, password, displayName); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("organization first admin created", zap.String("org_id", orgID))
|
|
return nil
|
|
}
|
|
|
|
// NewService builds the org service. enabled mirrors features.multiTenancy.
|
|
func NewService(store *Store, accounts AccountMigrator, data DataMigrator, enabled bool, log *logger.Logger) *Service {
|
|
return &Service{store: store, accounts: accounts, data: data, enabled: enabled,
|
|
log: log.WithFields(zap.String("component", "org-service"))}
|
|
}
|
|
|
|
// Enabled reports whether organizations are on.
|
|
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
|
|
|
// ValidateStartup refuses the one combination that cannot work.
|
|
func ValidateStartup(multiTenancy, auth bool) error {
|
|
if multiTenancy && !auth {
|
|
return ErrTenancyRequiresAuth
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureDefaultOrg runs the tenancy migration: it creates the single default
|
|
// organization if none exists, puts every unassigned user and workspace into
|
|
// it, drops membership rows that would straddle two organizations, and grants
|
|
// the operator tier to the first admin so an upgraded instance is never left
|
|
// with nobody able to manage organizations.
|
|
//
|
|
// It is idempotent, and it is a no-op when organizations are off.
|
|
func (s *Service) EnsureDefaultOrg(ctx context.Context, instanceName string) (*Org, error) {
|
|
if !s.Enabled() {
|
|
return nil, nil
|
|
}
|
|
existing, err := s.store.Default(ctx)
|
|
if err != nil && !errors.Is(err, ErrOrgNotFound) {
|
|
return nil, err
|
|
}
|
|
if existing == nil {
|
|
name := strings.TrimSpace(instanceName)
|
|
if name == "" {
|
|
name = "Default organization"
|
|
}
|
|
existing, err = s.store.Create(ctx, name, Slugify(name), true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create default organization: %w", err)
|
|
}
|
|
s.log.Info("created the default organization",
|
|
zap.String("org_id", existing.ID), zap.String("slug", existing.Slug))
|
|
}
|
|
|
|
users, err := s.accounts.AssignUsersWithoutOrg(ctx, existing.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assign users to the default organization: %w", err)
|
|
}
|
|
workspaces, err := s.data.AssignWorkspacesWithoutOrg(ctx, existing.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assign workspaces to the default organization: %w", err)
|
|
}
|
|
dropped, err := s.data.DropCrossOrgWorkspaceMembers(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("drop cross-organization memberships: %w", err)
|
|
}
|
|
if users > 0 || workspaces > 0 || dropped > 0 {
|
|
s.log.Info("tenancy migration applied",
|
|
zap.Int64("users_assigned", users),
|
|
zap.Int64("workspaces_assigned", workspaces),
|
|
zap.Int64("cross_org_memberships_dropped", dropped))
|
|
}
|
|
|
|
if err := s.ensureOperator(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
// ensureOperator grants the operator tier to the first admin when nobody holds
|
|
// it. Without this an upgraded instance would have organizations and no way to
|
|
// manage them.
|
|
func (s *Service) ensureOperator(ctx context.Context) error {
|
|
count, err := s.accounts.CountOperators(ctx)
|
|
if err != nil || count > 0 {
|
|
return err
|
|
}
|
|
adminID, err := s.accounts.FirstAdminID(ctx)
|
|
if err != nil || adminID == "" {
|
|
return err
|
|
}
|
|
if err := s.accounts.SetOperator(ctx, adminID, true); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("granted the instance operator tier to the first admin", zap.String("user_id", adminID))
|
|
return nil
|
|
}
|
|
|
|
// Get returns an organization by ID.
|
|
func (s *Service) Get(ctx context.Context, id string) (*Org, error) { return s.store.Get(ctx, id) }
|
|
|
|
// List returns every organization.
|
|
func (s *Service) List(ctx context.Context) ([]*Org, error) { return s.store.List(ctx) }
|
|
|
|
// Create adds an organization.
|
|
func (s *Service) Create(ctx context.Context, name, slug string) (*Org, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, ErrNameRequired
|
|
}
|
|
if slug = Slugify(slug); slug == "org" {
|
|
slug = Slugify(name)
|
|
}
|
|
org, err := s.store.Create(ctx, name, slug, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.log.Info("organization created", zap.String("org_id", org.ID), zap.String("slug", org.Slug))
|
|
return org, nil
|
|
}
|
|
|
|
// SetStatus suspends or resumes an organization. Suspension is reversible and
|
|
// retains every row and file; it is the lever for a billing lapse.
|
|
func (s *Service) SetStatus(ctx context.Context, id, status string) (*Org, error) {
|
|
org, err := s.store.UpdateNameStatus(ctx, id, "", status)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.log.Info("organization status changed",
|
|
zap.String("org_id", id), zap.String("status", org.Status))
|
|
return org, nil
|
|
}
|
|
|
|
// Rename changes an organization's display name. The slug is immutable
|
|
// because it is the confirmation token for deletion.
|
|
func (s *Service) Rename(ctx context.Context, id, name string) (*Org, error) {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, ErrNameRequired
|
|
}
|
|
return s.store.UpdateNameStatus(ctx, id, strings.TrimSpace(name), "")
|
|
}
|
|
|
|
// Delete removes an organization and all of its data. It requires the caller
|
|
// to type the slug, refuses the last remaining organization, and removes the
|
|
// org row last so a failure part-way leaves the org still identifiable.
|
|
func (s *Service) Delete(ctx context.Context, id, confirmSlug string) error {
|
|
org, err := s.store.Get(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if confirmSlug != org.Slug {
|
|
return ErrSlugMismatch
|
|
}
|
|
count, err := s.store.Count(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count <= 1 {
|
|
return ErrCannotDeleteLast
|
|
}
|
|
if err := s.data.DeleteOrgData(ctx, id); err != nil {
|
|
return fmt.Errorf("delete organization data: %w", err)
|
|
}
|
|
if err := s.accounts.DeleteUsersByOrg(ctx, id); err != nil {
|
|
return fmt.Errorf("delete organization accounts: %w", err)
|
|
}
|
|
if s.units != nil {
|
|
if err := s.units.DeleteOrgUnits(ctx, id); err != nil {
|
|
return fmt.Errorf("delete organization units: %w", err)
|
|
}
|
|
}
|
|
if err := s.store.Delete(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("organization deleted", zap.String("org_id", id), zap.String("slug", org.Slug))
|
|
return nil
|
|
}
|
|
|
|
// ClaimSetupAdmin places the account created by the authentication setup
|
|
// wizard into the default organization and grants it the instance operator
|
|
// tier. It is a no-op when organizations are off.
|
|
func (s *Service) ClaimSetupAdmin(ctx context.Context, userID string) error {
|
|
if !s.Enabled() || userID == "" {
|
|
return nil
|
|
}
|
|
defaultOrg, err := s.EnsureDefaultOrg(ctx, "Default organization")
|
|
if err != nil || defaultOrg == nil {
|
|
return err
|
|
}
|
|
if err := s.accounts.SetOperator(ctx, userID, true); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("setup admin placed in the default organization",
|
|
zap.String("user_id", userID), zap.String("org_id", defaultOrg.ID))
|
|
return nil
|
|
}
|