feat(agentnetwork): require identity echo on settings PUT, add guarded DELETE

Lands the review decision from #7085: PUT keeps the API-wide convention
of requiring every field. endpoint and proxy_address join the update
schema as required fields, compared against the stored row (trimmed,
case-folded) and rejected with 422 on mismatch. They are never written,
so the identity stays immutable while the request shape stays
conventional.

Because the identity is immutable, clients that model change-as-replace
(the Terraform provider's RequiresReplace) need a real delete. DELETE
/api/agent-network/settings now exists with two guards, both checked
under the row lock: no providers may exist for the account, and no
active proxy may declare the endpoint hostname as its cluster address.
Either refusal is a 412. The proxy guard checks the endpoint hostname
rather than the proxy address: for a dedicated pin they are equal, and
for a labeled pin the shared parent cluster being up says nothing about
this account once its providers are gone. Re-creating after a delete
allocates a fresh endpoint; the released hostname is not reserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brad Ison
2026-08-10 16:57:22 +02:00
co-authored by Claude Fable 5
parent e60e7c9089
commit 16b42e402c
16 changed files with 531 additions and 32 deletions
+16
View File
@@ -146,6 +146,8 @@ func TestSettingsRoundTrip(t *testing.T) {
beforeRetention := *before.AccessLogRetentionDays
flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: before.Endpoint,
ProxyAddress: before.ProxyAddress,
EnableLogCollection: !before.EnableLogCollection,
EnablePromptCollection: !before.EnablePromptCollection,
RedactPii: !before.RedactPii,
@@ -167,8 +169,22 @@ func TestSettingsRoundTrip(t *testing.T) {
})
requireClientError(t, err)
// The identity fields ride along on the PUT as a required echo: a request
// carrying a different endpoint is rejected without applying anything.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: "other.cluster.invalid",
ProxyAddress: before.ProxyAddress,
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
AccessLogRetentionDays: beforeRetention,
})
requireClientError(t, err)
// Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: before.Endpoint,
ProxyAddress: before.ProxyAddress,
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
+41 -4
View File
@@ -90,9 +90,12 @@ func TestSettingsBootstrapViaPost(t *testing.T) {
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, PUT updates the toggles; the identity fields are not
// part of its schema and survive by construction.
// Once bootstrapped, PUT updates the toggles. The identity fields ride
// along as a required echo of the assigned values; a matching echo is
// accepted and never written.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: bootstrapped.Endpoint,
ProxyAddress: bootstrapped.ProxyAddress,
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
@@ -106,8 +109,19 @@ func TestSettingsBootstrapViaPost(t *testing.T) {
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The endpoint is immutable: a second bootstrap is rejected as a
// conflict, and the rejected create must not disturb anything.
// The endpoint is immutable: a PUT carrying a different endpoint is
// rejected, and a second bootstrap is rejected as a conflict. Neither
// rejected write may disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: "other.cluster.invalid",
ProxyAddress: persisted.ProxyAddress,
EnableLogCollection: persisted.EnableLogCollection,
EnablePromptCollection: persisted.EnablePromptCollection,
RedactPii: persisted.RedactPii,
AccessLogRetentionDays: 21,
})
requireClientError(t, err)
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("other.cluster.invalid"),
})
@@ -126,6 +140,9 @@ func TestSettingsBootstrapViaPost(t *testing.T) {
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
// equals it, and the pin reads as dedicated — the address-first flow a
// self-hosted operator uses before deploying the proxy that will declare it.
// The tail covers the recovery path the guarded DELETE exists for: with no
// providers and no proxy at the address, the claim can be released and a
// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint.
func TestSettingsBootstrapSelfAddressed(t *testing.T) {
ctx := context.Background()
@@ -139,4 +156,24 @@ func TestSettingsBootstrapSelfAddressed(t *testing.T) {
assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim")
assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
assert.True(t, created.Dedicated, "a self-addressed pin is dedicated")
// No providers exist and no proxy declares the address, so both delete
// guards are clear: the delete releases the claim and the account reads
// as unbootstrapped defaults again.
require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed")
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after delete must succeed")
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped")
// A second delete has nothing to remove.
requireClientError(t, fresh.DeleteSettings(ctx))
// Re-creating is a fresh bootstrap — the released hostname is free to be
// claimed again, or a different one chosen.
recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("gw2.e2e.netbird.selfhosted"),
})
require.NoError(t, err, "bootstrap after delete must succeed")
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
}