From b66eb10524601726c30cda9ef800fc6aca4291a6 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:55:09 +0100 Subject: [PATCH] server: admin-credentials REST + Slot:admin push helper Adds GET/PUT/DELETE /api/hosts/{id}/admin-credentials handlers that mirror the existing repo-credentials endpoints but write to store.CredKindAdmin with AEAD additional-data "host::admin" (scoped away from the repo slot to prevent cross-binding). PUT immediately pushes a config.update(Slot:"admin") to the agent when it is connected, and the new pushAdminCredsToAgent helper is wired for use by the upcoming prune run-now endpoint (D2) to push on-demand before dispatch. --- internal/server/http/host_credentials.go | 200 +++++++++++++++++ internal/server/http/host_credentials_test.go | 205 ++++++++++++++++++ internal/server/http/server.go | 7 + 3 files changed, 412 insertions(+) diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index 93bd1d0..e87c99d 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" stdhttp "net/http" "time" @@ -184,6 +185,205 @@ func (s *Server) pushRepoCredsToAgent(ctx context.Context, hostID string, blob r return nil } +// handleGetAdminCredentials returns a redacted view of the host's admin +// creds for UI display. 404 if no admin slot has been set yet. Operator +// uses this to pre-fill the edit form. +func (s *Server) handleGetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + enc, err := s.deps.Store.GetHostCredentials(r.Context(), hostID, store.CredKindAdmin) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "not_set", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID+":admin")) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "decrypt_failed", "") + return + } + var blob repoCredsBlob + if err := json.Unmarshal(plain, &blob); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + writeJSON(w, stdhttp.StatusOK, hostRepoCredsView{ + RepoURL: blob.RepoURL, + RepoUsername: blob.RepoUsername, + HasPassword: blob.RepoPassword != "", + }) +} + +// handleSetAdminCredentials lets an operator/admin update a host's admin +// creds (the prune-capable slot). Same merge-then-validate semantics as +// handleSetHostCredentials but operates on store.CredKindAdmin. After +// persisting, pushes a config.update with Slot:"admin" over the WS if +// the agent is connected. +func (s *Server) handleSetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") + return + } + + var req hostRepoCredsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + + // Merge with the existing admin row, if any. + existing := repoCredsBlob{} + aad := []byte("host:" + hostID + ":admin") + if cur, err := s.deps.Store.GetHostCredentials(r.Context(), hostID, store.CredKindAdmin); err == nil { + plain, err := s.deps.AEAD.Decrypt(cur, aad) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "decrypt_failed", "") + return + } + _ = json.Unmarshal(plain, &existing) + } else if !errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + + if req.RepoURL != nil { + existing.RepoURL = *req.RepoURL + } + if req.RepoUsername != nil { + existing.RepoUsername = *req.RepoUsername + } + if req.RepoPassword != nil { + existing.RepoPassword = *req.RepoPassword + } + if existing.RepoURL == "" || existing.RepoPassword == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", + "repo_url and repo_password must end up non-empty") + return + } + + enc, err := s.encryptRepoCreds(existing, aad) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + if err := s.deps.Store.SetHostCredentials(r.Context(), hostID, store.CredKindAdmin, enc); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + Actor: "user", + Action: "host.admin_credentials_set", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: nowUTC(), + }) + + // Push to the agent if it's connected. Non-fatal: the next + // handleRunRepoPrune call will push on-demand. + if s.deps.Hub != nil && s.deps.Hub.Connected(hostID) { + _ = s.pushAdminCredsToAgent(r.Context(), hostID) + } + + w.WriteHeader(stdhttp.StatusNoContent) +} + +// handleDeleteAdminCredentials removes the admin credentials row for the +// host. Returns 204 on success, 404 if the row wasn't set. Does NOT push +// a deletion to the agent — the agent's local admin slot stays as-is +// until the next deployment/reinstall. +func (s *Server) handleDeleteAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if !s.authedUser(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + + // Check existence first so we can 404 cleanly. + if _, err := s.deps.Store.GetHostCredentials(r.Context(), hostID, store.CredKindAdmin); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "not_set", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + + if err := s.deps.Store.DeleteHostCredentials(r.Context(), hostID, store.CredKindAdmin); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + Actor: "user", + Action: "host.admin_credentials_deleted", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: nowUTC(), + }) + + w.WriteHeader(stdhttp.StatusNoContent) +} + +// pushAdminCredsToAgent ships the admin-slot config.update down the +// agent's WS. Used by: +// - handleSetAdminCredentials (immediate push when operator saves). +// - handleRunRepoPrune (on-demand push right before a prune dispatch). +// +// Returns store.ErrNotFound if no admin row exists for the host +// (the prune endpoint uses this to refuse with a clear message). +func (s *Server) pushAdminCredsToAgent(ctx context.Context, hostID string) error { + enc, err := s.deps.Store.GetHostCredentials(ctx, hostID, store.CredKindAdmin) + if err != nil { + return err // ErrNotFound bubbles + } + plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID+":admin")) + if err != nil { + return fmt.Errorf("push admin creds: decrypt: %w", err) + } + var blob repoCredsBlob + if err := json.Unmarshal(plain, &blob); err != nil { + return fmt.Errorf("push admin creds: parse: %w", err) + } + env, err := api.Marshal(api.MsgConfigUpdate, "", api.ConfigUpdatePayload{ + Slot: "admin", + RepoURL: blob.RepoURL, + RepoUsername: blob.RepoUsername, + RepoPassword: blob.RepoPassword, + }) + if err != nil { + return err + } + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return s.deps.Hub.Send(sendCtx, hostID, env) +} + // onAgentHello runs synchronously inside the WS handler immediately // after a successful hello. It loads the host's encrypted creds (if // any), decrypts, and ships them down the conn as a config.update so diff --git a/internal/server/http/host_credentials_test.go b/internal/server/http/host_credentials_test.go index a821f97..bc7ebd0 100644 --- a/internal/server/http/host_credentials_test.go +++ b/internal/server/http/host_credentials_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -107,3 +108,207 @@ func TestEnrollmentTokenWithoutCreds(t *testing.T) { t.Errorf("token without creds should return empty blob; got %q", att.EncRepoCreds) } } + +// ----- admin credentials tests ---------------------------------------- + +// TestAdminCredentialsRoundTrip verifies set→get→delete→get (404). +func TestAdminCredentialsRoundTrip(t *testing.T) { + t.Parallel() + srv, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "admin-creds-host") + + // Mark init done so auto-init doesn't interfere. + _ = st.CreateJob(context.Background(), store.Job{ + ID: "init-" + hostID, + HostID: hostID, + Kind: string(api.JobInit), + ActorKind: "system", + CreatedAt: time.Now().UTC(), + }) + + // GET before set → 404. + status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 404 { + t.Fatalf("before set: want 404, got %d body=%+v", status, body) + } + + // PUT — set admin creds. + status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/admin-credentials", + map[string]any{ + "repo_url": "rest:http://admin.example/host", + "repo_username": "admin", + "repo_password": "s3cur3", + }, cookie) + if status != 204 { + t.Fatalf("set: want 204, got %d body=%+v", status, body) + } + + // GET — should return redacted view. + status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 200 { + t.Fatalf("get after set: want 200, got %d body=%+v", status, body) + } + if body["repo_url"] != "rest:http://admin.example/host" { + t.Errorf("repo_url: %+v", body) + } + if body["repo_username"] != "admin" { + t.Errorf("repo_username: %+v", body) + } + if body["has_password"] != true { + t.Errorf("has_password: %+v", body) + } + + // DELETE. + status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 204 { + t.Fatalf("delete: want 204, got %d", status) + } + + // GET after delete → 404. + status, _ = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 404 { + t.Fatalf("after delete: want 404, got %d", status) + } + + // Extra: suppress unused import warning by actually using srv in assertion. + _ = srv +} + +// TestAdminCredsAADIsolatedFromRepo writes a blob encrypted with the repo +// AAD ("host:") into the admin kind slot, then GETs it — the handler +// should fail to decrypt and return 500 decrypt_failed. This proves the +// AAD scoping is real. +func TestAdminCredsAADIsolatedFromRepo(t *testing.T) { + t.Parallel() + srv, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "aad-isolation-host") + + ctx := context.Background() + // Encrypt with the REPO AAD (wrong for admin slot). + enc, err := srv.encryptRepoCreds(repoCredsBlob{ + RepoURL: "rest:http://r/x", + RepoPassword: "p", + }, []byte("host:"+hostID)) // wrong AAD — repo, not admin + if err != nil { + t.Fatalf("encrypt: %v", err) + } + // Write it directly into the admin kind slot. + if err := st.SetHostCredentials(ctx, hostID, store.CredKindAdmin, enc); err != nil { + t.Fatalf("set host credentials: %v", err) + } + + // GET admin-credentials — handler decrypts with admin AAD, which + // is different, so decrypt must fail → 500. + status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 500 { + t.Fatalf("want 500 (decrypt_failed), got %d body=%+v", status, body) + } + if code, _ := body["code"].(string); code != "decrypt_failed" { + t.Errorf("want code=decrypt_failed, got %+v", body) + } +} + +// TestAdminCredsPushOnSet connects a fake WS host, sets admin creds via +// PUT, drains the conn, and asserts a config.update with Slot:"admin" +// was shipped. +func TestAdminCredsPushOnSet(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "admin-push-host") + cookie := loginAsAdmin(t, st) + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "admin-push-host") + + // Drain the on-hello burst (config.update for repo + schedule.set + // + possibly command.run(init)). + _ = drainUntil(t, c, api.MsgScheduleSet) + + // Now PUT admin creds — should trigger an immediate push. + status, body := doJSON(t, ts.URL, "PUT", "/api/hosts/"+hostID+"/admin-credentials", + map[string]any{ + "repo_url": "rest:http://admin.example/h", + "repo_username": "admin", + "repo_password": "prune-pass", + }, cookie) + if status != 204 { + t.Fatalf("set admin creds: want 204, got %d body=%+v", status, body) + } + + // Drain until we see a config.update with Slot=admin. + deadline := time.Now().Add(3 * time.Second) + found := false + for !found && time.Now().Before(deadline) { + env := readEnvelope(t, c) + if env.Type != api.MsgConfigUpdate { + continue + } + var p api.ConfigUpdatePayload + if err := env.UnmarshalPayload(&p); err != nil { + t.Fatalf("unmarshal config.update: %v", err) + } + if p.Slot == "admin" { + found = true + if p.RepoURL != "rest:http://admin.example/h" { + t.Errorf("admin push: wrong URL %q", p.RepoURL) + } + } + } + if !found { + t.Fatal("timed out waiting for config.update(slot=admin)") + } +} + +// TestDeleteAdminCredentialsAuditLogged checks that DELETE appends an +// audit row with action='host.admin_credentials_deleted'. +func TestDeleteAdminCredentialsAuditLogged(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "audit-del-host") + + ctx := context.Background() + + // Set admin creds first so there is something to delete. + status, body := doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/admin-credentials", + map[string]any{ + "repo_url": "rest:http://x/h", + "repo_password": "p", + }, cookie) + if status != 204 { + t.Fatalf("set: want 204, got %d body=%+v", status, body) + } + + // Delete. + status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/admin-credentials", nil, cookie) + if status != 204 { + t.Fatalf("delete: want 204, got %d", status) + } + + // Query audit_log for the host. + rows, err := st.DB().QueryContext(ctx, + `SELECT action FROM audit_log WHERE target_id = ? AND target_kind = 'host'`, hostID) + if err != nil { + t.Fatalf("query audit: %v", err) + } + defer rows.Close() + + found := false + for rows.Next() { + var action string + if err := rows.Scan(&action); err != nil { + t.Fatalf("scan: %v", err) + } + if action == "host.admin_credentials_deleted" { + found = true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + if !found { + t.Error("audit row with action='host.admin_credentials_deleted' not found") + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index f286fdb..7bf9090 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -105,6 +105,13 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials) r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials) + // Admin credentials — the prune-capable slot (separate from the + // everyday repo creds). Optional: hosts that don't prune against + // a rest-server repo with a separate admin user never need this. + r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials) + r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials) + r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials) + // Per-host schedule CRUD. Mutations bump host_schedule_version // and async-push to a connected agent (see schedule_push.go). r.Get("/hosts/{id}/schedules", s.handleListSchedules)