Files
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00

387 lines
12 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package applyplan
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// This file repairs one specific defect in Stalwart's own migrate_v016.py,
// which this tool downloads rather than vendors and therefore cannot fix at
// the source.
//
// In v0.15 a domain's tenant and a principal's tenant were independent
// facts. A tenant-scoped user could sit on a domain owned by no tenant at
// all - which is the normal outcome whenever the domain was never declared
// as its own `domain` principal, because migrate_v016.py then infers the
// domain from an email address, and inferred domains carry no tenant.
//
// v0.16 rejects that arrangement. A tenant-scoped Account may only
// reference a Domain owned by the same tenant, as a primary domain or as an
// alias, and the server answers:
//
// invalidForeignKey | Object id: Domain#d
//
// on the Domain reference otherwise. This was confirmed against a real
// 0.16.14 in recovery mode, not inferred: the reverse direction (a global,
// tenant-less account on a tenant-owned domain) applies without complaint,
// and only the tenant-scoped-account-to-tenant-less-domain direction fails.
//
// That error cost a production migration a full restore, because it
// surfaced during apply - after the old service was already stopped, and
// after the store had been irreversibly upgraded to schema v6. Hence both
// halves of the fix: repair the plan here, and refuse the genuinely
// unrepresentable cases in preflight, before anything is stopped.
//
// The same fix has been prepared for migrate_v016.py upstream; this stays
// until a released migrate_v016.py carries it, and is written to be a no-op
// against a plan that is already consistent.
// TenantAdoption records one domain that took on a tenant it did not
// previously declare, so the operator is told rather than silently given a
// different ownership model than they had.
type TenantAdoption struct {
Domain string // domain name, e.g. "example.com"
Tenant string // tenant name, or the raw reference if the name is unknown
Because string // the principal whose membership forced it
}
// TenantConflict is a domain whose users disagree about which tenant owns
// it. v0.16 cannot represent this, so it is reported rather than repaired.
type TenantConflict struct {
Domain string
Detail string
Tenants []string
}
// TenantReconcileResult is what a reconciliation changed and what it could
// not change.
type TenantReconcileResult struct {
Adoptions []TenantAdoption
Conflicts []TenantConflict
}
// OK reports whether the plan is now internally consistent.
func (r TenantReconcileResult) OK() bool { return len(r.Conflicts) == 0 }
// String renders a short operator-facing summary.
func (r TenantReconcileResult) String() string {
if len(r.Adoptions) == 0 && len(r.Conflicts) == 0 {
return "no domain/tenant mismatches"
}
var b strings.Builder
for _, a := range r.Adoptions {
fmt.Fprintf(&b, "domain %s adopted tenant %s (required by %s)\n", a.Domain, a.Tenant, a.Because)
}
for _, c := range r.Conflicts {
fmt.Fprintf(&b, "domain %s: %s\n", c.Domain, c.Detail)
}
return strings.TrimRight(b.String(), "\n")
}
// PlanOp is one line of an apply plan, kept in the generic form it was
// read in.
//
// It is deliberately not the typed Operation above. A real export.json
// mixes shapes: `create` operations map a client-id to an object, while
// `update` operations - SystemSettings, BlobStore, SearchStore - carry a
// flat object with no client-id layer at all. Parsing a whole plan into the
// typed form fails on the first `update` line, and a reconciliation that
// cannot read the plan it is meant to repair is worse than none.
type PlanOp map[string]any
// ReadPlan parses an apply plan - migrate_v016.py's export.json, or one
// this tool generated.
//
// Numbers are decoded as json.Number so they survive the round-trip
// unchanged. Decoding into float64 would rewrite a quota of 10737418240 as
// 1.073741824e+10, which is a different document than the one the
// conversion produced.
func ReadPlan(path string) ([]PlanOp, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("applyplan: open %s: %w", path, err)
}
defer f.Close()
var ops []PlanOp
sc := bufio.NewScanner(f)
// Plans carry certificates and DKIM keys inline, so lines run well past
// bufio.Scanner's default 64KiB limit.
sc.Buffer(make([]byte, 0, 64<<10), 8<<20)
for line := 1; sc.Scan(); line++ {
raw := strings.TrimSpace(sc.Text())
if raw == "" {
continue
}
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
var op PlanOp
if err := dec.Decode(&op); err != nil {
return nil, fmt.Errorf("applyplan: %s line %d: %w", path, line, err)
}
ops = append(ops, op)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("applyplan: read %s: %w", path, err)
}
return ops, nil
}
// WritePlan writes plan operations atomically, so a failure part-way
// through can never leave a truncated plan that would apply half a
// migration.
func WritePlan(path string, ops []PlanOp) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return fmt.Errorf("applyplan: create temp file in %s: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
enc := json.NewEncoder(tmp)
for _, op := range ops {
if err := enc.Encode(op); err != nil {
tmp.Close()
return fmt.Errorf("applyplan: write %s: %w", tmpName, err)
}
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("applyplan: sync %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("applyplan: close %s: %w", tmpName, err)
}
if err := os.Chmod(tmpName, 0o640); err != nil {
return fmt.Errorf("applyplan: chmod %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("applyplan: replace %s: %w", path, err)
}
return nil
}
// objectName is the operation's target type, e.g. "Domain".
func (op PlanOp) objectName() string {
s, _ := op["object"].(string)
return s
}
// createBodies returns the client-id-keyed objects a `create` operation
// carries. Operations whose value is a flat object - every `update` - yield
// nothing, which is what keeps them untouched.
func (op PlanOp) createBodies() map[string]map[string]any {
value, ok := op["value"].(map[string]any)
if !ok {
return nil
}
out := map[string]map[string]any{}
for cid, body := range value {
if b, ok := body.(map[string]any); ok {
out[cid] = b
}
}
if len(out) == 0 {
return nil
}
return out
}
// refCID strips the leading "#" from a plan back-reference. Values that are
// not back-references (an already-resolved server id, say) are returned
// unchanged, which is what makes comparing two references safe.
func refCID(v any) string {
s, ok := v.(string)
if !ok || s == "" {
return ""
}
return strings.TrimPrefix(s, "#")
}
// domainRefs returns every domain reference an account or mailing list
// makes: its primary domain, plus one per alias. Aliases matter as much as
// the primary - a tenant-scoped account with an alias on a tenant-less
// domain is rejected exactly like one whose primary domain mismatches.
func domainRefs(body map[string]any) []string {
var refs []string
if c := refCID(body["domainId"]); c != "" {
refs = append(refs, c)
}
aliases, ok := body["aliases"].(map[string]any)
if !ok {
return refs
}
for _, a := range aliases {
alias, ok := a.(map[string]any)
if !ok {
continue
}
if c := refCID(alias["domainId"]); c != "" {
refs = append(refs, c)
}
}
return refs
}
func stringField(body map[string]any, key string) string {
s, _ := body[key].(string)
return s
}
// ReconcileDomainTenants makes every domain's tenant agree with the
// accounts and mailing lists that use it, editing ops in place.
//
// Where a domain has no tenant but every tenant-scoped principal using it
// agrees on one, the domain adopts that tenant: it is the only assignment
// that lets the plan apply while keeping every account, and it is safe in
// the other direction because a global account on a tenant-owned domain is
// accepted.
//
// Where the principals disagree, nothing is changed and the disagreement is
// reported. Forcing such a plan through would mean dropping accounts, and
// dropping accounts silently is how mailboxes get lost.
func ReconcileDomainTenants(ops []PlanOp) TenantReconcileResult {
var result TenantReconcileResult
domains := map[string]map[string]any{} // cid -> domain body
tenantNames := map[string]string{} // cid -> tenant name
for _, op := range ops {
bodies := op.createBodies()
switch op.objectName() {
case "Domain":
for cid, body := range bodies {
domains[cid] = body
}
case "Tenant":
for cid, body := range bodies {
tenantNames[cid] = stringField(body, "name")
}
}
}
if len(domains) == 0 || len(tenantNames) == 0 {
return result // single-tenant install: nothing can mismatch
}
tenantName := func(ref string) string {
if n := tenantNames[refCID(ref)]; n != "" {
return n
}
return refCID(ref)
}
// domain cid -> tenant ref -> an example principal requiring it.
required := map[string]map[string]string{}
for _, op := range ops {
object := op.objectName()
if object != "Account" && object != "MailingList" {
continue
}
for _, body := range op.createBodies() {
tRef, _ := body["memberTenantId"].(string)
if tRef == "" {
continue // a global principal constrains nothing
}
label := fmt.Sprintf("%s %q", object, stringField(body, "name"))
for _, dCID := range domainRefs(body) {
if required[dCID] == nil {
required[dCID] = map[string]string{}
}
if _, seen := required[dCID][tRef]; !seen {
required[dCID][tRef] = label
}
}
}
}
for _, dCID := range sortedKeys(required) {
wanted := required[dCID]
dom, ok := domains[dCID]
if !ok {
continue // reference to something this plan does not create
}
dName := stringField(dom, "name")
if dName == "" {
dName = dCID
}
if len(wanted) > 1 {
var names, parts []string
for _, tRef := range sortedKeys(wanted) {
names = append(names, tenantName(tRef))
parts = append(parts, fmt.Sprintf("%s (e.g. %s)", tenantName(tRef), wanted[tRef]))
}
result.Conflicts = append(result.Conflicts, TenantConflict{
Domain: dName,
Tenants: names,
Detail: fmt.Sprintf("used by principals from more than one tenant: %s. "+
"v0.16 requires every tenant-scoped account to sit on a domain owned by its own "+
"tenant, and a domain can belong to at most one tenant", strings.Join(parts, ", ")),
})
continue
}
tRef := sortedKeys(wanted)[0]
because := wanted[tRef]
current, _ := dom["memberTenantId"].(string)
if refCID(current) == refCID(tRef) {
continue // already consistent
}
if current != "" {
result.Conflicts = append(result.Conflicts, TenantConflict{
Domain: dName,
Tenants: []string{tenantName(current), tenantName(tRef)},
Detail: fmt.Sprintf("belongs to tenant %s, but %s belongs to tenant %s and uses it. "+
"v0.16 rejects an account whose tenant differs from its domain's",
tenantName(current), because, tenantName(tRef)),
})
continue
}
dom["memberTenantId"] = tRef
result.Adoptions = append(result.Adoptions, TenantAdoption{
Domain: dName, Tenant: tenantName(tRef), Because: because,
})
}
return result
}
// ReconcileDomainTenantsFile reads a plan, reconciles it, and rewrites it
// only if something actually changed and nothing conflicted.
func ReconcileDomainTenantsFile(path string) (TenantReconcileResult, error) {
ops, err := ReadPlan(path)
if err != nil {
return TenantReconcileResult{}, err
}
result := ReconcileDomainTenants(ops)
if !result.OK() {
return result, fmt.Errorf("applyplan: %s cannot be made consistent with v0.16's "+
"tenant rules:\n%s", path, result.String())
}
if len(result.Adoptions) == 0 {
return result, nil
}
if err := WritePlan(path, ops); err != nil {
return result, err
}
return result, nil
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}