Files
stalwart-migrator/internal/applyplan/roles.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

169 lines
5.7 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package applyplan
import (
"fmt"
"sort"
"strings"
"github.com/LINUXexpert-org/stalwart-migrator/internal/backup"
)
// AccountRoleOperations restores the account roles a v0.16 migration drops.
//
// migrate_v016.py assigns every migrated account the User role regardless
// of what it had before. Verified on a real migration: an account holding
// the admin role in v0.15 came out the other side with
// `roles: {"@type": "User"}`, authenticated fine, and was refused every
// management call with "forbidden". The instance had no working
// administrator, which is a bad thing to discover after cutting over.
//
// Ordinary users are unaffected - User is what they had and what they get -
// so this deliberately emits operations only for accounts whose role
// actually changes. A plan that rewrote every account would be a much
// larger blast radius for no benefit.
//
// The v0.16 shape comes from the server's own schema document
// (GET /api/schema), where x:UserRoles is a multi-variant type with
// variants User, Admin and Custom, and from confirming the upsert against
// a live 0.16.14: Account is itself multi-variant, so the entry needs its
// own "@type" as well.
func AccountRoleOperations(principals []backup.Principal) (ops []Operation, covered []string, warnings []string, err error) {
type change struct {
localPart string
domain string
variant string
source string
}
var changes []change
// v0.16 stores an account as a local part plus a domain reference, not
// as the full address v0.15 uses for its principal name: a v0.15
// account named "[email protected]" becomes name "john" with a domainId.
// Passing the full address is rejected outright ("Invalid email local
// part"), which is how this was found - on a production clone, where
// accounts are named by address. The smoke instance used bare names and
// never exercised it.
localPartCount := map[string]int{}
for _, p := range principals {
if strings.EqualFold(p.Type, "individual") {
localPartCount[localPart(p)]++
}
}
for _, p := range principals {
if !strings.EqualFold(p.Type, "individual") {
continue // domains and groups don't carry these roles
}
variant, note := roleVariant(p.Roles)
if note != "" {
warnings = append(warnings, fmt.Sprintf("account %q: %s", p.Name, note))
}
if variant == "" || variant == "User" {
// User is the migration's own default; re-asserting it would
// touch every account to no effect.
continue
}
local := localPart(p)
// Local parts are unique only within a domain, so an upsert matched
// on name alone could land on a different account that happens to
// share it - [email protected] and [email protected] both become
// "postmaster". Granting Admin to the wrong account is worse than
// granting it to none, so an ambiguous name is refused and
// reported rather than guessed at.
if localPartCount[local] > 1 {
warnings = append(warnings, fmt.Sprintf(
"account %q: %d accounts share the local part %q, so this role cannot be restored unambiguously - grant it by hand after migrating",
p.Name, localPartCount[local], local))
continue
}
changes = append(changes, change{localPart: local, domain: domainOf(p), variant: variant, source: p.Name})
}
sort.Slice(changes, func(i, j int) bool { return changes[i].localPart < changes[j].localPart })
for _, c := range changes {
value := map[string]any{
// Account is a multi-variant object: without its own @type the
// upsert is rejected outright.
"@type": "User",
"name": c.localPart,
"roles": map[string]any{"@type": c.variant},
}
ops = append(ops, Operation{
Type: "upsert",
Object: "Account",
MatchOn: []string{"name"},
Value: map[string]map[string]any{"role-" + c.localPart: value},
})
covered = append(covered, "principal:"+c.source+":roles")
}
return ops, covered, warnings, nil
}
// localPart is the account name v0.16 will hold: the part before "@" when
// the v0.15 principal is named by address, otherwise the name as-is.
func localPart(p backup.Principal) string {
name := p.Name
if name == "" && len(p.Emails) > 0 {
name = p.Emails[0]
}
if at := strings.Index(name, "@"); at > 0 {
return name[:at]
}
return name
}
// domainOf reports the domain an account belongs to, for messages.
func domainOf(p backup.Principal) string {
name := p.Name
if at := strings.Index(name, "@"); at > 0 && at+1 < len(name) {
return name[at+1:]
}
if len(p.Emails) > 0 {
if at := strings.Index(p.Emails[0], "@"); at > 0 && at+1 < len(p.Emails[0]) {
return p.Emails[0][at+1:]
}
}
return ""
}
// roleVariant maps a v0.15 role list onto v0.16's x:UserRoles variant.
//
// v0.15 carries a list of role names; v0.16 carries one variant. Where an
// account had several, admin wins - under-privileging an administrator
// locks them out, which is the failure this function exists to prevent -
// and the collapse is reported rather than done silently.
func roleVariant(roles []string) (variant, note string) {
if len(roles) == 0 {
return "", ""
}
hasAdmin, hasUser := false, false
var unknown []string
for _, r := range roles {
switch strings.ToLower(strings.TrimSpace(r)) {
case "admin", "administrator":
hasAdmin = true
case "user":
hasUser = true
default:
unknown = append(unknown, r)
}
}
if len(unknown) > 0 {
note = fmt.Sprintf("role(s) %s have no known v0.16 equivalent and are not restored - recreate them by hand",
strings.Join(unknown, ", "))
}
switch {
case hasAdmin && (hasUser || len(unknown) > 0):
return "Admin", note + " (collapsed several roles to Admin)"
case hasAdmin:
return "Admin", note
case hasUser:
return "User", note
default:
return "", note
}
}