From 25866ff1026f472760cb7a7694f3683438be3f66 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sat, 22 Aug 2026 13:42:42 +0100 Subject: [PATCH] Preserve source group hooks through the API --- internal/server/http/p2r01_test.go | 26 +++++++++++++++++ internal/server/http/source_groups.go | 40 ++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/server/http/p2r01_test.go b/internal/server/http/p2r01_test.go index 07451db..51a42ff 100644 --- a/internal/server/http/p2r01_test.go +++ b/internal/server/http/p2r01_test.go @@ -153,6 +153,8 @@ func TestSourceGroupsCRUD(t *testing.T) { }, "retry_max": 3, "retry_backoff_seconds": 60, + "pre_hook": "prepare-db", + "post_hook": "resume-db", }, cookie) if status != 201 { t.Fatalf("create status: %d, body: %+v", status, body) @@ -161,6 +163,16 @@ func TestSourceGroupsCRUD(t *testing.T) { if gid == "" { t.Fatalf("create: no id returned: %+v", body) } + if body["has_pre_hook"] != true || body["has_post_hook"] != true { + t.Fatalf("create hook indicators: %+v", body) + } + stored, err := st.GetSourceGroup(context.Background(), hostID, gid) + if err != nil { + t.Fatalf("get stored group: %v", err) + } + if stored.PreHook == "prepare-db" || stored.PostHook == "resume-db" || stored.PreHook == "" || stored.PostHook == "" { + t.Fatalf("hooks not encrypted at rest: pre=%q post=%q", stored.PreHook, stored.PostHook) + } // Duplicate name → 409. status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups", @@ -185,6 +197,20 @@ func TestSourceGroupsCRUD(t *testing.T) { if got := body["name"]; got != "system" { t.Errorf("rename: got %v want system", got) } + stored, _ = st.GetSourceGroup(context.Background(), hostID, gid) + if stored.PreHook == "" || stored.PostHook == "" { + t.Fatal("PUT without hook fields cleared existing hooks") + } + + // Explicit empty hook clears only that hook; plaintext is never returned. + status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid, + map[string]any{"name": "system", "includes": []string{"/etc"}, "pre_hook": ""}, cookie) + if status != 200 || body["has_pre_hook"] != false || body["has_post_hook"] != true { + t.Fatalf("clear hook status=%d body=%+v", status, body) + } + if _, exposed := body["post_hook"]; exposed { + t.Fatal("source-group response exposed hook plaintext field") + } // Delete. status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie) diff --git a/internal/server/http/source_groups.go b/internal/server/http/source_groups.go index 88c2688..00cfb72 100644 --- a/internal/server/http/source_groups.go +++ b/internal/server/http/source_groups.go @@ -29,6 +29,8 @@ type sourceGroupView struct { RetryMax int `json:"retry_max"` RetryBackoffSeconds int `json:"retry_backoff_seconds"` ConflictDimension string `json:"conflict_dimension,omitempty"` + HasPreHook bool `json:"has_pre_hook"` + HasPostHook bool `json:"has_post_hook"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -49,6 +51,8 @@ func toSourceGroupView(g store.SourceGroup) sourceGroupView { RetryMax: g.RetryMax, RetryBackoffSeconds: g.RetryBackoffSeconds, ConflictDimension: g.ConflictDimension, + HasPreHook: g.PreHook != "", + HasPostHook: g.PostHook != "", CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } @@ -62,6 +66,29 @@ type sourceGroupWriteRequest struct { RetentionPolicy store.RetentionPolicy `json:"retention_policy"` RetryMax int `json:"retry_max"` RetryBackoffSeconds int `json:"retry_backoff_seconds"` + // Pointer fields distinguish omission (preserve on PUT) from an explicit + // empty string (clear). Hook plaintext is accepted on write but never + // returned by the JSON API. + PreHook *string `json:"pre_hook,omitempty"` + PostHook *string `json:"post_hook,omitempty"` +} + +func (s *Server) applySourceGroupHooks(hostID string, req sourceGroupWriteRequest, g *store.SourceGroup) error { + if req.PreHook != nil { + enc, err := s.EncryptHookForGroup(hostID, "pre", *req.PreHook) + if err != nil { + return err + } + g.PreHook = enc + } + if req.PostHook != nil { + enc, err := s.EncryptHookForGroup(hostID, "post", *req.PostHook) + if err != nil { + return err + } + g.PostHook = enc + } + return nil } func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -142,6 +169,10 @@ func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re RetryMax: req.RetryMax, RetryBackoffSeconds: req.RetryBackoffSeconds, } + if err := s.applySourceGroupHooks(hostID, req, &g); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "") + return + } if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return @@ -157,7 +188,8 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re } hostID := chi.URLParam(r, "id") groupID := chi.URLParam(r, "gid") - if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil { + existingGroup, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID) + if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") return @@ -188,6 +220,12 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re RetentionPolicy: req.RetentionPolicy, RetryMax: req.RetryMax, RetryBackoffSeconds: req.RetryBackoffSeconds, + PreHook: existingGroup.PreHook, + PostHook: existingGroup.PostHook, + } + if err := s.applySourceGroupHooks(hostID, req, &g); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "") + return } if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())