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>
174 lines
6.4 KiB
Go
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)
|
|
}
|