store: host_credentials becomes kind-aware (repo + admin slots)

This commit is contained in:
2026-05-03 22:06:05 +01:00
parent 9f2cb18e42
commit f801fdf65b
7 changed files with 151 additions and 23 deletions
+34 -11
View File
@@ -8,13 +8,23 @@ import (
"time"
)
// CredentialKind identifies the role of a host_credentials row.
type CredentialKind string
const (
// CredKindRepo is the append-only credential used for every backup.
CredKindRepo CredentialKind = "repo"
// CredKindAdmin is the delete-capable credential used for prune.
CredKindAdmin CredentialKind = "admin"
)
// GetHostCredentials returns the AEAD-encrypted repo creds blob for
// the host, or ("", ErrNotFound) if no credential has ever been set.
// the host + kind, or ("", ErrNotFound) if no matching row exists.
// The caller decrypts using host_id as AEAD additional data.
func (s *Store) GetHostCredentials(ctx context.Context, hostID string) (string, error) {
func (s *Store) GetHostCredentials(ctx context.Context, hostID string, kind CredentialKind) (string, error) {
row := s.db.QueryRowContext(ctx,
`SELECT enc_repo_creds FROM host_credentials WHERE host_id = ?`,
hostID)
`SELECT enc_repo_creds FROM host_credentials WHERE host_id = ? AND kind = ?`,
hostID, string(kind))
var enc string
if err := row.Scan(&enc); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -25,22 +35,35 @@ func (s *Store) GetHostCredentials(ctx context.Context, hostID string) (string,
return enc, nil
}
// SetHostCredentials replaces the host's encrypted repo creds blob.
// The caller has already encrypted using host_id as additional data.
func (s *Store) SetHostCredentials(ctx context.Context, hostID, encRepoCreds string) error {
// SetHostCredentials replaces the host's encrypted repo creds blob for
// the given kind. The caller has already encrypted using host_id as
// additional data.
func (s *Store) SetHostCredentials(ctx context.Context, hostID string, kind CredentialKind, encRepoCreds string) error {
if encRepoCreds == "" {
return fmt.Errorf("store: empty enc_repo_creds")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO host_credentials (host_id, enc_repo_creds, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
`INSERT INTO host_credentials (host_id, kind, enc_repo_creds, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(host_id, kind) DO UPDATE SET
enc_repo_creds = excluded.enc_repo_creds,
updated_at = excluded.updated_at`,
hostID, encRepoCreds, now)
hostID, string(kind), encRepoCreds, now)
if err != nil {
return fmt.Errorf("store: set host credentials: %w", err)
}
return nil
}
// DeleteHostCredentials removes the credential row for the given host
// and kind. A no-op if the row does not exist.
func (s *Store) DeleteHostCredentials(ctx context.Context, hostID string, kind CredentialKind) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM host_credentials WHERE host_id = ? AND kind = ?`,
hostID, string(kind))
if err != nil {
return fmt.Errorf("store: delete host credentials: %w", err)
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package store
import (
"context"
"errors"
"testing"
)
// seedHost inserts a minimal host row for testing.
func seedHost(t *testing.T, s *Store, hostID string) {
t.Helper()
_, err := s.DB().Exec(
`INSERT INTO hosts (id, name, os, arch, enrolled_at) VALUES (?,?,?,?,?)`,
hostID, hostID, "linux", "amd64", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatalf("seed host %q: %v", hostID, err)
}
}
func TestHostCredentialsAdminRowSeparate(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-creds-test"
seedHost(t, s, hostID)
const repoBlob = "enc-repo-blob"
const adminBlob = "enc-admin-blob"
// Set repo creds.
if err := s.SetHostCredentials(ctx, hostID, CredKindRepo, repoBlob); err != nil {
t.Fatalf("set repo creds: %v", err)
}
// Set admin creds.
if err := s.SetHostCredentials(ctx, hostID, CredKindAdmin, adminBlob); err != nil {
t.Fatalf("set admin creds: %v", err)
}
// Fetch each by kind and assert they differ.
gotRepo, err := s.GetHostCredentials(ctx, hostID, CredKindRepo)
if err != nil {
t.Fatalf("get repo creds: %v", err)
}
gotAdmin, err := s.GetHostCredentials(ctx, hostID, CredKindAdmin)
if err != nil {
t.Fatalf("get admin creds: %v", err)
}
if gotRepo != repoBlob {
t.Errorf("repo creds: got %q, want %q", gotRepo, repoBlob)
}
if gotAdmin != adminBlob {
t.Errorf("admin creds: got %q, want %q", gotAdmin, adminBlob)
}
if gotRepo == gotAdmin {
t.Error("repo and admin blobs must differ")
}
// Delete admin; repo must be unaffected.
if err := s.DeleteHostCredentials(ctx, hostID, CredKindAdmin); err != nil {
t.Fatalf("delete admin creds: %v", err)
}
if _, err := s.GetHostCredentials(ctx, hostID, CredKindAdmin); !errors.Is(err, ErrNotFound) {
t.Errorf("after delete, expected ErrNotFound for admin; got %v", err)
}
if got, err := s.GetHostCredentials(ctx, hostID, CredKindRepo); err != nil || got != repoBlob {
t.Errorf("repo creds should survive admin delete; got %q, err %v", got, err)
}
}
func TestHostCredentialsNotFound(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
_, err := s.GetHostCredentials(ctx, "no-such-host", CredKindRepo)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestHostCredentialsUpsert(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
const hostID = "h-upsert-test"
seedHost(t, s, hostID)
if err := s.SetHostCredentials(ctx, hostID, CredKindRepo, "v1"); err != nil {
t.Fatalf("set v1: %v", err)
}
if err := s.SetHostCredentials(ctx, hostID, CredKindRepo, "v2"); err != nil {
t.Fatalf("set v2 (upsert): %v", err)
}
got, err := s.GetHostCredentials(ctx, hostID, CredKindRepo)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != "v2" {
t.Errorf("expected v2, got %q", got)
}
}