Files
stalwart-migrator/internal/stalwartapi/tenantlayout.go
T
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

292 lines
8.5 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwartapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
)
// TenantLayout is who-belongs-to-which-tenant on a v0.15.x instance, in
// enough detail to predict whether the v0.16 conversion will produce a plan
// the new server accepts.
//
// v0.16 requires a tenant-scoped account to sit on a domain owned by that
// same tenant, for its primary domain and for every alias. v0.15 imposed no
// such rule, so an install can be perfectly valid today and unconvertible
// tomorrow. Establishing that here - while the server is still running and
// nothing has been stopped - is the whole point: the alternative is finding
// out during the recovery-mode apply, which is the one moment in the run
// with no way forward and no way back.
type TenantLayout struct {
// Tenants is every tenant principal's name.
Tenants []string
// DomainTenant maps a declared domain name to its tenant name. Domains
// that exist only inside an email address are absent, which mirrors the
// converter: it infers those domains and gives them no tenant.
DomainTenant map[string]string
// Principals is every account, group and mailing list, with the tenant
// it belongs to and the domains it touches.
Principals []PrincipalTenancy
}
// PrincipalTenancy is one account's tenant and the domains it references.
type PrincipalTenancy struct {
Name string
Type string
Tenant string // "" for a global principal
Domains []string // primary plus alias domains, lowercased
}
// flexString reads a field that a v0.15 instance may return either as a
// plain string or wrapped - migrate_v016.py's own pv_string tolerates the
// same shapes, and a preflight that only understood one of them would
// silently see every account as global.
func flexString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err == nil {
for _, key := range []string{"string", "name", "id"} {
if v, ok := obj[key]; ok {
var inner string
if json.Unmarshal(v, &inner) == nil && inner != "" {
return inner
}
}
}
return ""
}
var list []json.RawMessage
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return flexString(list[0])
}
return ""
}
// detailedPrincipal is the per-principal view, which carries the tenant the
// paginated list does not reliably include.
type detailedPrincipal struct {
Type json.RawMessage `json:"type"`
Name json.RawMessage `json:"name"`
Tenant json.RawMessage `json:"tenant"`
Emails []string `json:"emails"`
}
// principalDetail fetches one principal by name. The response may or may
// not be wrapped in a "data" envelope depending on the point release, so
// both are accepted.
func (c *Client) principalDetail(ctx context.Context, name string) (detailedPrincipal, error) {
var out detailedPrincipal
endpoint := strings.TrimRight(c.BaseURL, "/") + "/api/principal/" + url.PathEscape(name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return out, err
}
req.SetBasicAuth(c.Username, c.Password)
resp, err := c.httpClient().Do(req)
if err != nil {
return out, fmt.Errorf("stalwartapi: fetch principal %q: %w", name, err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("stalwartapi: GET %s returned %s", endpoint, resp.Status)
}
if readErr != nil {
return out, fmt.Errorf("stalwartapi: read principal %q: %w", name, readErr)
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
payload := body
if json.Unmarshal(body, &envelope) == nil && len(envelope.Data) > 0 {
payload = envelope.Data
}
if err := json.Unmarshal(payload, &out); err != nil {
return out, fmt.Errorf("stalwartapi: parse principal %q: %w", name, err)
}
return out, nil
}
// domainsOf returns every domain a principal touches: the domain in its
// name, if it is an address, plus one per email.
func domainsOf(name string, emails []string) []string {
seen := map[string]bool{}
var out []string
add := func(addr string) {
at := strings.LastIndex(addr, "@")
if at < 0 || at == len(addr)-1 {
return
}
d := strings.ToLower(strings.TrimSpace(addr[at+1:]))
if d == "" || seen[d] {
return
}
seen[d] = true
out = append(out, d)
}
add(name)
for _, e := range emails {
add(e)
}
sort.Strings(out)
return out
}
// FetchTenantLayout builds a TenantLayout from a v0.15.x instance.
//
// It returns an empty layout, and no error, on a single-tenant install:
// there is nothing that can mismatch, and that is the common case.
func (c *Client) FetchTenantLayout(ctx context.Context) (*TenantLayout, error) {
tenants, err := c.TenantNames(ctx)
if err != nil {
return nil, err
}
layout := &TenantLayout{Tenants: tenants, DomainTenant: map[string]string{}}
if len(tenants) == 0 {
return layout, nil
}
domains, err := c.restPrincipals(ctx, "domain")
if err != nil {
return nil, err
}
for _, d := range domains {
if d.Name == "" {
continue
}
detail, err := c.principalDetail(ctx, d.Name)
if err != nil {
return nil, err
}
layout.DomainTenant[strings.ToLower(d.Name)] = flexString(detail.Tenant)
}
for _, pType := range []string{"individual", "group", "list"} {
principals, err := c.restPrincipals(ctx, pType)
if err != nil {
return nil, err
}
for _, p := range principals {
if p.Name == "" {
continue
}
detail, err := c.principalDetail(ctx, p.Name)
if err != nil {
return nil, err
}
emails := p.Emails
if len(detail.Emails) > 0 {
emails = detail.Emails
}
layout.Principals = append(layout.Principals, PrincipalTenancy{
Name: p.Name,
Type: pType,
Tenant: flexString(detail.Tenant),
Domains: domainsOf(p.Name, emails),
})
}
}
return layout, nil
}
// TenancyProblem is one domain whose users cannot all be represented in
// v0.16.
type TenancyProblem struct {
Domain string
Detail string
}
// TenancyPlan is what the conversion will have to do to this layout.
type TenancyPlan struct {
// Adoptions are domains with no tenant of their own that will be given
// one, because the only tenant-scoped accounts using them agree.
Adoptions []string
// Problems are the domains v0.16 cannot represent at all.
Problems []TenancyProblem
}
// Analyze predicts whether this layout converts cleanly, applying exactly
// the rule the server enforces and the same repair applyplan performs.
func (l *TenantLayout) Analyze() TenancyPlan {
var plan TenancyPlan
if len(l.Tenants) == 0 {
return plan
}
// domain -> tenant -> an example principal requiring it
required := map[string]map[string]string{}
for _, p := range l.Principals {
if p.Tenant == "" {
continue // a global principal constrains nothing
}
for _, d := range p.Domains {
if required[d] == nil {
required[d] = map[string]string{}
}
if _, ok := required[d][p.Tenant]; !ok {
required[d][p.Tenant] = p.Name
}
}
}
domains := make([]string, 0, len(required))
for d := range required {
domains = append(domains, d)
}
sort.Strings(domains)
for _, d := range domains {
wanted := required[d]
names := make([]string, 0, len(wanted))
for t := range wanted {
names = append(names, t)
}
sort.Strings(names)
if len(names) > 1 {
var parts []string
for _, t := range names {
parts = append(parts, fmt.Sprintf("%s (e.g. %s)", t, wanted[t]))
}
plan.Problems = append(plan.Problems, TenancyProblem{
Domain: d,
Detail: fmt.Sprintf("used by accounts from more than one tenant: %s - v0.16 allows a domain "+
"to belong to at most one tenant, and requires each tenant-scoped account to sit on its "+
"own tenant's domain", strings.Join(parts, ", ")),
})
continue
}
want := names[0]
switch declared, isDeclared := l.DomainTenant[d]; {
case !isDeclared || declared == "":
// Either inferred from an address, or declared with no tenant.
// Either way the conversion gives it no tenant and the account
// is rejected - this is the case that broke production.
plan.Adoptions = append(plan.Adoptions, d)
case declared != want:
plan.Problems = append(plan.Problems, TenancyProblem{
Domain: d,
Detail: fmt.Sprintf("belongs to tenant %s, but %s (tenant %s) uses it - v0.16 rejects an "+
"account whose tenant differs from its domain's", declared, wanted[want], want),
})
}
}
return plan
}