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:<id>: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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user