From a05a74cf4d07926e42e3dc48d5bc7347567ac59d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 16:31:27 +0100 Subject: [PATCH] feat(server): refuse local edits to hq-sourced users The API is the boundary; hiding the control in web/ is a courtesy. A role editable in two places is a role with two answers. Co-Authored-By: Claude Opus 5 --- server/internal/api/instance.go | 4 +++- server/internal/services/users.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/internal/api/instance.go b/server/internal/api/instance.go index b32235a..05a0bf2 100644 --- a/server/internal/api/instance.go +++ b/server/internal/api/instance.go @@ -111,7 +111,9 @@ func deleteInstanceUser(c *gin.Context) { } func orgUserErrStatus(err error) int { - if errors.Is(err, services.ErrLastOwner) { + // 409 rather than 403: the caller has the right to manage members, and the + // request is refused because of the resource's state, not their permissions. + if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) { return http.StatusConflict } return http.StatusInternalServerError diff --git a/server/internal/services/users.go b/server/internal/services/users.go index c040aa2..fb408a3 100644 --- a/server/internal/services/users.go +++ b/server/internal/services/users.go @@ -16,6 +16,16 @@ import ( var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first") +// ErrHQManaged is returned when a caller tries to change a user this instance +// does not own. +// +// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role, +// its password and its existence. A role editable in two places is a role with +// two answers, and the loser is whichever writer ran first. Refusing here +// rather than merely hiding the control in web/ is the point — the API is the +// boundary, the UI is a courtesy. +var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal") + func CountUsers() (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -122,6 +132,9 @@ func UpdateUserRole(instanceID, userID, role string) error { if err != nil { return fmt.Errorf("user not found") } + if target.AuthSource == models.AuthHQ { + return ErrHQManaged + } if target.Role == models.RoleOwner && role != models.RoleOwner { others, err := countOtherOwners(instanceID, userID) @@ -146,6 +159,9 @@ func DeleteUser(instanceID, userID string) error { if err != nil { return fmt.Errorf("user not found") } + if target.AuthSource == models.AuthHQ { + return ErrHQManaged + } if target.Role == models.RoleOwner { others, err := countOtherOwners(instanceID, userID) if err != nil {