Documentation
¶
Index ¶
- Constants
- Variables
- func CertIdentityChanged(before, after *Host) bool
- func FriendlyAddrError(field, value string) string
- func FriendlyPrefixError(field, value string) string
- func HostDiff(before, after *Host) ([]byte, bool, error)
- func ValidKind(k HostKind) bool
- func ValidMeshImportStatus(status MeshImportStatus) bool
- func ValidOperatorRole(r string) bool
- func ValidRole(r HostRole) bool
- func ValidVariant(v HostVariant) bool
- func ValidateCIDR(field, value string) (netip.Prefix, error)
- func ValidateFirewallCIDR(field, value string) error
- func ValidateFirewallSelectors(group, cidr string) error
- func ValidateHostAddresses(addrs []string) error
- func ValidateHostAdvanced(adv *HostAdvanced) error
- func ValidateIPAddr(field, value string) (netip.Addr, error)
- func ValidateMobileConstraints(kind HostKind, variant HostVariant, role HostRole) error
- func ValidateNetworkCIDRs(cidrs []string) error
- func ValidateRoleReachability(role HostRole, publicIP string, listenPort int) error
- type AgentProfile
- type AuditEntry
- type CA
- type CAStatus
- type CertificateIdentity
- type CertificateInfo
- type EnrollmentToken
- type Host
- type HostAdvanced
- type HostAgentProfile
- type HostFirewallRule
- type HostKind
- type HostRole
- type HostStatus
- type HostVariant
- type MeshImport
- type MeshImportChallenge
- type MeshImportRegistration
- type MeshImportRegistrationResult
- type MeshImportSnapshot
- type MeshImportStatus
- type MeshImportTombstone
- type Network
- type Operator
- type OperatorAPIKey
- type OperatorAuthProvider
- type OperatorSession
- type OperatorStatus
- type SessionState
- type UnsafeRoute
- type WebhookSubscription
Constants ¶
const ( OperatorRoleAdmin = "admin" OperatorRoleUser = "user" )
Operator roles. Operator.Role is a plain string; these are the only two values the server accepts.
const MaxAddressesPerHost = 16
MaxAddressesPerHost is the maximum number of overlay addresses a host can have. This is a soft limit to prevent cert bloat; Nebula v2 certs have no hard limit but practical deployments should not exceed this without good reason.
const MaxGroupNameLen = 64
MaxGroupNameLen bounds a group name. Group names are embedded in the signed Nebula certificate and distributed to every peer, so without a cap an operator could bloat certs mesh-wide (#186). It lives here, in the domain layer, because both certificate group validation and firewall rule validation must enforce the same bound.
const MaxHostFirewallRules = 64
MaxHostFirewallRules caps advanced.firewall_inbound per host. Generous relative to real use, but bounded so a single host cannot bloat its rendered config without limit.
Variables ¶
var ErrMobileRoleRestricted = errors.New("mobile hosts must have role=host (lighthouse/relay not supported)")
ErrMobileRoleRestricted is returned when a mobile host is assigned a role other than host (e.g., lighthouse or relay). Mobile Nebula clients cannot reliably listen on a public socket in the background, so these roles are not supported for mobile hosts.
var ErrMobileVariantRequired = errors.New("mobile hosts must have variant set to ios or android")
ErrMobileVariantRequired is returned when a mobile host is created without specifying a variant (ios or android). The variant field is required to distinguish mobile client types.
var ErrRoleRequiresListenPort = errors.New("listen_port is required when role is lighthouse or relay")
ErrRoleRequiresListenPort is the listen_port counterpart of ErrRoleRequiresPublicIP: peers compose static_host_map entries as "public_ip:listen_port", so a zero port produces the same silent failure.
var ErrRoleRequiresPublicIP = errors.New("public_ip is required when role is lighthouse or relay")
ErrRoleRequiresPublicIP is returned when a host with role=lighthouse or role=relay is created without a non-empty public_ip. Such a host would never be advertised to peers (see internal/api/enroll.go where static_host_map / lighthouse.hosts only include hosts whose PublicIP is set), so accept-and-silently-drop is replaced with reject-at-create.
Functions ¶
func CertIdentityChanged ¶ added in v0.11.0
CertIdentityChanged reports whether an edit touched a field that is carried inside the host's Nebula certificate: Name, NebulaIPs or Groups.
Those three are the host's identity as far as the mesh is concerned. Peers authorize each other on the certificate rather than on the management server's host row, and firewall rules select their counterparties by group, so editing any of them is inert until a new certificate is issued. Callers use this to schedule that re-issuance. Every other field (Role, PublicIP, ListenPort, Advanced) only shapes the rendered config, which the agent picks up on its next poll without a new certificate.
Both host-edit paths — the API's PATCH handler and the web UI's form — must agree on this, hence one definition rather than a copy each. It deliberately reuses the same comparators as HostDiff above, so what the audit trail records as a change and what triggers a re-issuance cannot drift apart.
A nil side is treated as the zero Host, matching HostDiff.
func FriendlyAddrError ¶
FriendlyAddrError returns a stable user-facing message for an IP-parse failure. The Go stdlib's netip.ParseAddr error text is intentionally dropped — strings like `ParseAddr("10.42.0.22.333"): IPv4 address too long` are diagnostic for Go authors but useless to operators typing into a form. Callers pass the form field name (or empty for unqualified messages) and the operator-supplied value so the message identifies both what is wrong and where.
func FriendlyPrefixError ¶
FriendlyPrefixError is the CIDR counterpart of FriendlyAddrError.
func HostDiff ¶
HostDiff computes the difference between two hosts and returns a JSON-encoded map of changed fields. Returns (nil, false, nil) if no fields differ.
For basic fields (Name, NebulaIPs, Groups, Role, PublicIP, ListenPort), the diff key is the field name in snake_case.
For Advanced sub-fields (ListenHost, MTU, TunDevice, Punchy, UnsafeRoutes), the diff key uses dot-notation: "advanced.mtu", "advanced.punchy", etc.
The JSON format for each changed field is: {"field_name": {"before": <value>, "after": <value>}}
If before is nil, zero values are used. If before.Advanced is nil, zero values are used for sub-field comparisons.
func ValidMeshImportStatus ¶ added in v0.8.0
func ValidMeshImportStatus(status MeshImportStatus) bool
func ValidOperatorRole ¶ added in v0.4.0
ValidOperatorRole reports whether r is a known operator role.
func ValidVariant ¶
func ValidVariant(v HostVariant) bool
ValidVariant reports whether v is a known host variant (including empty).
func ValidateCIDR ¶
ValidateCIDR is a thin wrapper around netip.ParsePrefix that converts a failure into a user-facing FriendlyPrefixError.
func ValidateFirewallCIDR ¶ added in v0.12.0
ValidateFirewallCIDR validates a Nebula firewall `cidr` / `local_cidr` value: empty (field unset), the literal "any" (any address of any family), or a parseable prefix such as 10.0.0.0/24 or fd00::/8.
func ValidateFirewallSelectors ¶ added in v0.12.0
ValidateFirewallSelectors enforces that a firewall rule carries exactly one peer selector. Nebula OR's `group` and `cidr` (a rule listing both matches peers in the group *or* peers in the prefix, which is wider than either), so the two are mutually exclusive here; expressing that union takes two rules. An empty pair is rejected because Nebula treats a rule with no selector as match-any, a broader allow than any operator intends.
func ValidateHostAddresses ¶
ValidateHostAddresses validates a list of overlay addresses for a host. It checks that: - The list is non-empty - Each address is parseable - There are no duplicate addresses - The list does not exceed MaxAddressesPerHost
Note: This function does not check containment in parent network CIDRs. That validation is the responsibility of the caller (API or web handler) which has the parent network context and can provide better error messages.
func ValidateHostAdvanced ¶
func ValidateHostAdvanced(adv *HostAdvanced) error
ValidateHostAdvanced rejects obviously broken advanced overrides before they reach the database. Empty / zero-value fields mean "inherit network default" and pass validation. All error messages use the user-facing friendly wrappers so the inline form (web) and the JSON API surface identical, stable strings.
func ValidateIPAddr ¶
ValidateIPAddr is a thin wrapper around netip.ParseAddr that converts a failure into a user-facing FriendlyAddrError. Returns the parsed address on success.
func ValidateMobileConstraints ¶
func ValidateMobileConstraints(kind HostKind, variant HostVariant, role HostRole) error
ValidateMobileConstraints enforces role and variant constraints for mobile hosts. For kind=mobile, role must be host (or empty) and variant must be ios or android. For kind=agent, no constraints are applied (returns nil).
func ValidateNetworkCIDRs ¶
ValidateNetworkCIDRs validates a list of CIDRs for a network. It checks that: - The list is non-empty - Each CIDR is parseable - There are no duplicate CIDRs - There are no overlapping CIDRs
func ValidateRoleReachability ¶
ValidateRoleReachability rejects role/reachability combinations that would result in a silently-useless host. role=lighthouse and role=relay must both ship with a routable public_ip and a non-zero listen_port — otherwise peer config.yml renders an empty static_host_map and the host is never dialed (issue #94).
Types ¶
type AgentProfile ¶ added in v0.8.0
type AgentProfile struct {
NebulaConfigPath string `json:"nebula_config_path"`
NebulaCAPath string `json:"nebula_ca_path"`
NebulaCertPath string `json:"nebula_cert_path"`
NebulaKeyPath string `json:"nebula_key_path"`
ConfigAckV1 bool `json:"config_ack_v1"`
}
func DefaultAgentProfile ¶ added in v0.8.0
func DefaultAgentProfile() AgentProfile
func (AgentProfile) IsZero ¶ added in v0.8.0
func (p AgentProfile) IsZero() bool
func (AgentProfile) Validate ¶ added in v0.8.0
func (p AgentProfile) Validate() error
func (AgentProfile) WithDefaults ¶ added in v0.8.0
func (p AgentProfile) WithDefaults() (AgentProfile, error)
type AuditEntry ¶
type CA ¶
type CA struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerOperatorID string `json:"owner_operator_id"`
CertPEM string `json:"cert_pem"`
Fingerprint string `json:"fingerprint"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Status CAStatus `json:"status"`
PredecessorID *string `json:"predecessor_id,omitempty"`
EncryptedKeyDEK []byte `json:"-"`
NonceDEK []byte `json:"-"`
EncryptedKeyMaterial []byte `json:"-"`
NonceKey []byte `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
CA is a per-operator certificate authority. Private key material lives only inside EncryptedKeyMaterial / NonceKey, wrapped under a per-CA DEK stored in EncryptedKeyDEK / NonceDEK (envelope encryption — see ADR 0002).
type CertificateIdentity ¶ added in v0.11.0
CertificateIdentity is the subset of a Host that is carried inside its Nebula certificate.
It is intentionally separate from Host so callers that sign a certificate can retain precisely the identity they signed without also retaining mutable lifecycle or configuration state.
func CertificateIdentityFromHost ¶ added in v0.11.0
func CertificateIdentityFromHost(host *Host) CertificateIdentity
CertificateIdentityFromHost returns an independent snapshot of the fields that are carried in a Host's Nebula certificate. A nil Host is the zero identity, matching HostDiff's nil-host convention.
func (CertificateIdentity) Equal ¶ added in v0.11.0
func (identity CertificateIdentity) Equal(other CertificateIdentity) bool
Equal reports whether two certificate identities describe the same Nebula certificate subject. Empty and nil slices are equivalent, as they are when a Host is diffed after a store or JSON round trip.
type CertificateInfo ¶
type CertificateInfo struct {
ID string `json:"id"`
HostID string `json:"host_id"`
Fingerprint string `json:"fingerprint"`
PEM string `json:"pem"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
IsCurrent bool `json:"is_current"`
CreatedAt time.Time `json:"created_at"`
}
type EnrollmentToken ¶
type EnrollmentToken struct {
ID string `json:"id"`
HostID string `json:"host_id"`
TokenHash string `json:"-"` // versioned keyed verifier; raw value is never persisted
Used bool `json:"used"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Host ¶
type Host struct {
ID string `json:"id"`
NetworkID string `json:"network_id"`
CAID string `json:"ca_id,omitempty"`
Name string `json:"name"`
NebulaIPs []string `json:"nebula_ips"`
Groups []string `json:"groups"`
Role HostRole `json:"role"`
IsLighthouse bool `json:"is_lighthouse"`
IsRelay bool `json:"is_relay"`
PublicIP string `json:"public_ip,omitempty"`
ListenPort int `json:"listen_port,omitempty"`
Status HostStatus `json:"status"`
CertFingerprint string `json:"cert_fingerprint,omitempty"`
PrevCertFingerprint string `json:"prev_cert_fingerprint,omitempty"`
CertExpiresAt *time.Time `json:"cert_expires_at,omitempty"`
CertRotatedAt *time.Time `json:"cert_rotated_at,omitempty"`
PendingRekey bool `json:"pending_rekey,omitempty"`
SigningPubPEM string `json:"signing_pub_pem,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
Advanced *HostAdvanced `json:"advanced,omitempty"`
Kind HostKind `json:"kind"`
Variant HostVariant `json:"variant,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type HostAdvanced ¶
type HostAdvanced struct {
Punchy *bool `json:"punchy,omitempty" yaml:"punchy,omitempty"`
ListenHost string `json:"listen_host,omitempty" yaml:"listen_host,omitempty"`
MTU int `json:"mtu,omitempty" yaml:"mtu,omitempty"`
TunDevice string `json:"tun_device,omitempty" yaml:"tun_device,omitempty"`
UnsafeRoutes []UnsafeRoute `json:"unsafe_routes,omitempty" yaml:"unsafe_routes,omitempty"`
FirewallInbound []HostFirewallRule `json:"firewall_inbound,omitempty" yaml:"firewall_inbound,omitempty"`
}
HostAdvanced groups optional per-host overrides for the rendered Nebula config. All fields are optional. A field set to its zero value means "inherit network default"; a field set to a non-zero value overrides.
Punchy is a tri-state pointer so an operator can explicitly disable hole-punching for a host (false) without it being indistinguishable from "not set".
type HostAgentProfile ¶ added in v0.8.0
type HostAgentProfile struct {
HostID string `json:"host_id"`
MeshImportID string `json:"mesh_import_id"`
NebulaConfigPath string `json:"nebula_config_path"`
NebulaCAPath string `json:"nebula_ca_path"`
NebulaCertPath string `json:"nebula_cert_path"`
NebulaKeyPath string `json:"nebula_key_path"`
ConfigAckV1 bool `json:"config_ack_v1"`
PendingConfigVersion int `json:"pending_config_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (HostAgentProfile) AgentProfile ¶ added in v0.8.0
func (p HostAgentProfile) AgentProfile() AgentProfile
type HostFirewallRule ¶ added in v0.10.0
type HostFirewallRule struct {
Port string `json:"port" yaml:"port"` // "any", a port, or a range "a-b"
Proto string `json:"proto" yaml:"proto"` // any | tcp | udp | icmp
Group string `json:"group" yaml:"group"`
// Cidr restricts the rule to peers whose Nebula address falls inside the
// prefix: a CIDR, or "any" for any address of any family.
Cidr string `json:"cidr,omitempty" yaml:"cidr,omitempty"`
// LocalCidr restricts the rule to traffic whose local address falls
// inside the prefix: a CIDR, or "any". Needed to reach addresses served
// via unsafe_routes, which Nebula excludes by default.
LocalCidr string `json:"local_cidr,omitempty" yaml:"local_cidr,omitempty"`
}
HostFirewallRule is a single per-host inbound firewall rule, appended after the network-wide policy in the rendered Nebula config. Group "any" renders as `host: any` (match every peer), mirroring the network policy.
Cidr and LocalCidr map to Nebula's `cidr` and `local_cidr` rule fields. Nebula OR's the peer selectors (`host` / `group` / `groups` / `cidr`) and AND's `local_cidr` into whichever selector matched, so a rule carries exactly one peer selector — either Group or Cidr, never both, since the two together would widen the rule to "group OR cidr" rather than narrow it. LocalCidr is an independent constraint on the local (this host) address and may accompany either selector.
type HostRole ¶
type HostRole string
func (HostRole) Lighthouse ¶ added in v0.10.0
Lighthouse reports whether the role includes lighthouse duty.
type HostStatus ¶
type HostStatus string
const ( HostStatusPending HostStatus = "pending" HostStatusEnrolled HostStatus = "enrolled" HostStatusBlocked HostStatus = "blocked" HostStatusImporting HostStatus = "importing" )
type HostVariant ¶
type HostVariant string
const ( HostVariantNone HostVariant = "" HostVariantIOS HostVariant = "ios" HostVariantAndroid HostVariant = "android" )
type MeshImport ¶ added in v0.8.0
type MeshImport struct {
ID string `json:"id"`
NetworkID string `json:"network_id"`
CAID string `json:"ca_id"`
OwnerOperatorID string `json:"owner_operator_id"`
CAFingerprint string `json:"ca_fingerprint"`
Status MeshImportStatus `json:"status"`
ExpectedHosts *int `json:"expected_hosts,omitempty"`
Revision int64 `json:"revision"`
TokenHash string `json:"-"`
TokenExpiresAt time.Time `json:"token_expires_at"`
CapturedNetworkConfigVersion int `json:"captured_network_config_version"`
TerminalReason string `json:"terminal_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
CanceledAt *time.Time `json:"canceled_at,omitempty"`
}
type MeshImportChallenge ¶ added in v0.8.0
type MeshImportChallenge struct {
ID string `json:"id"`
MeshImportID string `json:"mesh_import_id"`
TokenHash string `json:"-"`
CertificateFingerprint string `json:"certificate_fingerprint"`
AgentSigningPubPEM string `json:"agent_signing_pub_pem"`
PayloadHash string `json:"payload_hash"`
ServerNonce string `json:"server_nonce"`
ExpiresAt time.Time `json:"expires_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type MeshImportRegistration ¶ added in v0.8.0
type MeshImportRegistration struct {
ChallengeID string
CertificateNotBefore time.Time
CertificateNotAfter time.Time
Host *Host
Snapshot *MeshImportSnapshot
Profile *HostAgentProfile
}
type MeshImportRegistrationResult ¶ added in v0.8.0
type MeshImportRegistrationResult struct {
Host *Host
Snapshot *MeshImportSnapshot
Created bool
}
type MeshImportSnapshot ¶ added in v0.8.0
type MeshImportSnapshot struct {
ID string `json:"id"`
MeshImportID string `json:"mesh_import_id"`
HostID string `json:"host_id"`
CertificateFingerprint string `json:"certificate_fingerprint"`
CertificatePEM string `json:"certificate_pem"`
AgentSigningPubPEM string `json:"agent_signing_pub_pem"`
PayloadHash string `json:"payload_hash"`
SnapshotJSON string `json:"snapshot_json"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type MeshImportStatus ¶ added in v0.8.0
type MeshImportStatus string
const ( MeshImportStatusCollecting MeshImportStatus = "collecting" MeshImportStatusFinalized MeshImportStatus = "finalized" MeshImportStatusCanceled MeshImportStatus = "canceled" )
type MeshImportTombstone ¶ added in v0.8.0
type MeshImportTombstone struct {
CertificateFingerprint string `json:"certificate_fingerprint"`
FormerHostID string `json:"former_host_id"`
MeshImportID string `json:"mesh_import_id"`
AgentSigningPubPEM string `json:"agent_signing_pub_pem"`
TerminalReason string `json:"terminal_reason"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Operator ¶
type Operator struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"`
AuthProvider OperatorAuthProvider `json:"auth_provider"`
Status OperatorStatus `json:"status"`
Role string `json:"role"`
TOTPSecret string `json:"-"`
TOTPEnabled bool `json:"totp_enabled"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// FailedLoginAttempts counts consecutive failed password logins; it
// resets to 0 on a successful login or when an expired lock is cleared
// (#263). LockedUntil, when non-nil and in the future, blocks login
// regardless of credentials.
FailedLoginAttempts int `json:"-"`
LockedUntil *time.Time `json:"-"`
}
Operator is an administrative user of the management server.
type OperatorAPIKey ¶
type OperatorAPIKey struct {
ID string `json:"id"`
OperatorID string `json:"operator_id"`
Name string `json:"name"`
KeyHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
OperatorAPIKey is a per-operator API key. Only the hash is stored.
type OperatorAuthProvider ¶
type OperatorAuthProvider string
OperatorAuthProvider identifies the authentication backend for an operator.
const ( OperatorAuthLocal OperatorAuthProvider = "local" OperatorAuthOIDC OperatorAuthProvider = "oidc" )
type OperatorSession ¶
type OperatorSession struct {
// Token is the raw session token carried in the operator's cookie. It is
// transient: the Store persists only a keyed verifier, never the raw value.
Token string
OperatorID string
State SessionState
ExpiresAt time.Time
CreatedAt time.Time
}
OperatorSession represents a UI session. A session in `pending_totp` state is awaiting a second-factor verification and is not yet authenticated.
type OperatorStatus ¶
type OperatorStatus string
OperatorStatus represents the active/disabled state of an operator.
const ( OperatorStatusActive OperatorStatus = "active" OperatorStatusDisabled OperatorStatus = "disabled" )
type SessionState ¶
type SessionState string
SessionState is the lifecycle phase of an operator session.
const ( SessionStateAuthenticated SessionState = "authenticated" SessionStatePendingTOTP SessionState = "pending_totp" )
type UnsafeRoute ¶
type UnsafeRoute struct {
Route string `json:"route" yaml:"route"` // CIDR
Via string `json:"via" yaml:"via"` // Nebula IP of the gateway host
}
UnsafeRoute is a single "unsafe route" entry: traffic for `Route` is sent through the host with Nebula IP `Via`. See Nebula's tun.unsafe_routes.
type WebhookSubscription ¶ added in v0.6.0
type WebhookSubscription struct {
ID string `json:"id"`
OwnerOperatorID string `json:"owner_operator_id"`
URL string `json:"url"`
Events []string `json:"events"` // empty = all events
Active bool `json:"active"`
AllowPrivate bool `json:"allow_private"`
// Envelope-encrypted HMAC secret. All nil => unsigned deliveries.
EncryptedSecretDEK []byte `json:"-"`
NonceDEK []byte `json:"-"`
EncryptedSecret []byte `json:"-"`
NonceSecret []byte `json:"-"`
// HasSecret is a computed, response-only flag (the secret itself is never
// returned). It is not persisted as a column.
HasSecret bool `json:"has_secret"`
// Per-subscription delivery observability.
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
LastStatus string `json:"last_status,omitempty"`
LastError string `json:"last_error,omitempty"`
ConsecutiveFailures int `json:"consecutive_failures"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
WebhookSubscription is an operator-owned outbound webhook target (#256 phase 2). The HMAC secret is stored envelope-encrypted (a per-row DEK wrapped under the master key, the secret sealed under the DEK), so the encrypted fields never serialize to JSON; API responses expose only HasSecret.