Files
DDI/server/internal/service/errors.go
T
sir_melonchenandClaude Opus 5.5 2b04661792 feat: clusters, TSIG keys, zones and records in service and REST API
Implements the M5 endpoints of the contract (ADR 0023): clusters with
member validation, transfer keys and HA credentials, TSIG keys with
audited secret access and rotation, explicit zones and typed records,
the DHCP cluster of networks, the cluster of agents and the default
clusters and HA password file of the generator settings. Revisions are
stored per target with sealed secret files and sealed source secrets
(restored for rollbacks); bundles carry an agent's own target or the
default group. Active leases are adopted as dynamic addresses.

The derivation is called through a temporary adapter until derive
implements the M5 contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:47:37 +02:00

174 lines
6.4 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package service implements the use cases of the management server. Each
// mutating operation runs in one transaction that also writes the audit log
// entry, so the log is complete by construction.
package service
import (
"errors"
"fmt"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// Kind classifies errors for the transport layer.
type Kind int
// Error kinds.
const (
KindInvalid Kind = iota + 1
KindNotFound
KindConflict
KindForbidden
KindUnauthorized
KindRateLimited
KindUnavailable
)
// Problem is one validation problem.
type Problem struct {
Code string `json:"code"`
Field string `json:"field,omitempty"`
Params map[string]string `json:"params,omitempty"`
}
// Error is a classified error with a stable code (ADR 0012). The web UI
// translates the code; Message is English and for logs.
type Error struct {
Kind Kind
Code string
Message string
Params map[string]string
Problems []Problem
// RetryAfter is set for KindRateLimited (seconds).
RetryAfter int
err error
}
func (e *Error) Error() string {
if e.err != nil {
return e.Message + ": " + e.err.Error()
}
return e.Message
}
func (e *Error) Unwrap() error { return e.err }
func newError(kind Kind, code, msg string) *Error {
return &Error{Kind: kind, Code: code, Message: msg}
}
// Common errors.
var (
ErrNotFound = newError(KindNotFound, "not_found", "not found")
ErrVersionConflict = newError(KindConflict, "version_conflict", "the object was changed by someone else; reload and try again")
ErrInvalidCredentials = newError(KindUnauthorized, "invalid_credentials", "invalid username or password")
ErrUnauthenticated = newError(KindUnauthorized, "unauthenticated", "not logged in")
ErrMFARequired = newError(KindUnauthorized, "mfa_required", "second factor required")
ErrInvalidTOTP = newError(KindInvalid, "invalid_totp", "invalid one-time code")
ErrForbidden = newError(KindForbidden, "forbidden", "permission denied")
ErrReadOnly = newError(KindForbidden, "license_read_only", "the server is read-only because no valid license is active")
ErrLastAdmin = newError(KindConflict, "last_admin", "the last active administrator cannot be removed, disabled or demoted")
ErrSelfDelete = newError(KindConflict, "self_delete", "you cannot delete your own account")
ErrNetworkNotEmpty = newError(KindConflict, "network_not_empty", "the network still has addresses")
ErrLicenseLimit = newError(KindForbidden, "license_limit", "the license limit is reached")
ErrPasswordChange = newError(KindForbidden, "password_change_required", "the password must be changed first")
ErrAlreadyInitialized = newError(KindConflict, "already_initialized", "users already exist")
ErrMapTooLarge = newError(KindInvalid, "map_too_large", "the address map is only available for IPv4 networks up to /22")
ErrOIDCDisabled = newError(KindNotFound, "oidc_disabled", "OIDC login is not configured")
)
// invalid wraps validation problems.
func invalid(problems ...Problem) *Error {
return &Error{Kind: KindInvalid, Code: "validation_failed", Message: "validation failed", Problems: problems}
}
func invalidField(code, field string) *Error { return invalid(Problem{Code: code, Field: field}) }
// fromIPAM converts domain validation errors.
func fromIPAM(errs ipam.Errors) *Error {
ps := make([]Problem, len(errs))
for i, e := range errs {
ps[i] = Problem{Code: e.Code, Field: e.Field, Params: e.Params}
}
return invalid(ps...)
}
// constraintCodes maps database constraints to API error codes.
var constraintCodes = map[string]struct {
code, field string
}{
"networks_no_overlap": {"network_overlap", "cidr"},
"networks_name_unique": {"network_name_taken", "name"},
"addresses_ip_unique": {"address_ip_taken", "ip"},
"addresses_mac_unique": {"mac_taken", "mac"},
"addresses_fqdn_unique": {"hostname_taken", "hostname"},
"addresses_ip_in_network": {"ip_outside_network", "ip"},
"users_username_unique": {"username_taken", "username"},
"users_username_format": {"invalid_username", "username"},
"users_oidc_unique": {"oidc_identity_taken", ""},
"saved_filters_unique": {"filter_name_taken", "name"},
// M5 (ADR 0023). The service checks these before writing; the
// constraints catch concurrent changes.
"tsig_keys_name_unique": {"key_exists", "name"},
"zone_update_keys_key_fk": {"key_in_use", ""},
"clusters_transfer_key_fk": {"key_owned_by_cluster", ""},
"clusters_name_unique": {"cluster_exists", "name"},
"cluster_members_pkey": {"agent_in_other_cluster", "members"},
"cluster_members_agent_fk": {"cluster_role_mismatch", "members"},
"cluster_members_one_primary": {"cluster_primary_exists", "members"},
"cluster_members_dhcp_one_secondary": {"cluster_member_limit", "members"},
"cluster_members_address_unique": {"duplicate_member", "members"},
"zones_name_unique": {"zone_exists", "name"},
"zones_cluster_fk": {"cluster_in_use", "cluster_id"},
"networks_dhcp_cluster_fk": {"cluster_in_use", "dhcp_cluster_id"},
"dns_records_unique": {"record_exists", ""},
}
// asUserError translates err and reports whether it is a classified error
// to report back to the user (rather than an internal failure).
func asUserError(err error) (*Error, bool) {
var se *Error
if errors.As(translate(err), &se) && se.Kind != 0 {
return se, true
}
return nil, false
}
// translate maps store errors to service errors; other errors pass through.
func translate(err error) error {
if err == nil {
return nil
}
var se *Error
if errors.As(err, &se) {
return err
}
switch {
case errors.Is(err, store.ErrNotFound):
return ErrNotFound
case errors.Is(err, store.ErrConflict):
return ErrVersionConflict
}
var ce *store.ConstraintError
if errors.As(err, &ce) {
if m, ok := constraintCodes[ce.Constraint]; ok {
e := invalidField(m.code, m.field)
e.Code = m.code
if ce.Code == "23505" || ce.Code == "23P01" {
e.Kind = KindConflict
}
e.err = err
return e
}
e := invalidField("constraint_violation", "")
e.Params = map[string]string{"constraint": ce.Constraint}
e.err = err
return e
}
return fmt.Errorf("internal error: %w", err)
}