From 618d5c5bb66874a269250aa5d9623717b859ce9d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:57:06 +0700 Subject: [PATCH] feat(store): persist per-domain custom record marks --- internal/store/db/customs.sql.go | 66 ++++++++++++++ internal/store/db/models.go | 7 ++ .../migrations/0005_domain_custom_records.sql | 11 +++ internal/store/queries/customs.sql | 10 +++ internal/store/store_test.go | 86 +++++++++++++++++++ internal/store/tenant.go | 28 ++++++ 6 files changed, 208 insertions(+) create mode 100644 internal/store/db/customs.sql.go create mode 100644 internal/store/migrations/0005_domain_custom_records.sql create mode 100644 internal/store/queries/customs.sql diff --git a/internal/store/db/customs.sql.go b/internal/store/db/customs.sql.go new file mode 100644 index 0000000..2554253 --- /dev/null +++ b/internal/store/db/customs.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: customs.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const addCustomRecord = `-- name: AddCustomRecord :exec +INSERT INTO domain_custom_records (domain_id, record_key) +VALUES ($1, $2) +ON CONFLICT (domain_id, record_key) DO NOTHING +` + +type AddCustomRecordParams struct { + DomainID uuid.UUID `json:"domain_id"` + RecordKey string `json:"record_key"` +} + +func (q *Queries) AddCustomRecord(ctx context.Context, arg AddCustomRecordParams) error { + _, err := q.db.Exec(ctx, addCustomRecord, arg.DomainID, arg.RecordKey) + return err +} + +const deleteCustomRecord = `-- name: DeleteCustomRecord :exec +DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2 +` + +type DeleteCustomRecordParams struct { + DomainID uuid.UUID `json:"domain_id"` + RecordKey string `json:"record_key"` +} + +func (q *Queries) DeleteCustomRecord(ctx context.Context, arg DeleteCustomRecordParams) error { + _, err := q.db.Exec(ctx, deleteCustomRecord, arg.DomainID, arg.RecordKey) + return err +} + +const listCustomKeys = `-- name: ListCustomKeys :many +SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key +` + +func (q *Queries) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, listCustomKeys, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var record_key string + if err := rows.Scan(&record_key); err != nil { + return nil, err + } + items = append(items, record_key) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/db/models.go b/internal/store/db/models.go index 0c440e7..080e7c6 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -17,6 +17,13 @@ type CheckRun struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type DomainCustomRecord struct { + DomainID uuid.UUID `json:"domain_id"` + RecordKey string `json:"record_key"` + Note string `json:"note"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Domain struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/internal/store/migrations/0005_domain_custom_records.sql b/internal/store/migrations/0005_domain_custom_records.sql new file mode 100644 index 0000000..c051042 --- /dev/null +++ b/internal/store/migrations/0005_domain_custom_records.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE domain_custom_records ( + domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + record_key text NOT NULL, + note text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (domain_id, record_key) +); + +-- +goose Down +DROP TABLE domain_custom_records; diff --git a/internal/store/queries/customs.sql b/internal/store/queries/customs.sql new file mode 100644 index 0000000..e833892 --- /dev/null +++ b/internal/store/queries/customs.sql @@ -0,0 +1,10 @@ +-- name: AddCustomRecord :exec +INSERT INTO domain_custom_records (domain_id, record_key) +VALUES ($1, $2) +ON CONFLICT (domain_id, record_key) DO NOTHING; + +-- name: DeleteCustomRecord :exec +DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2; + +-- name: ListCustomKeys :many +SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9572617..0955278 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -277,3 +277,89 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) { t.Fatal("expected error binding a template from a different project, got nil") } } + +// seedDomain creates a provider account and imports a single domain for +// defaultProject, returning the resulting Domain — the shared setup used by +// the custom-records tests below. +func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain { + t.Helper() + acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{ + ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob", + }) + if err != nil { + t.Fatal(err) + } + doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}}) + if err != nil { + t.Fatal(err) + } + return doms[0] +} + +// TestCustomRecordsAddIsIdempotentAndScoped verifies that AddCustomRecord +// is idempotent (ON CONFLICT DO NOTHING — no PK-conflict error on repeat) +// and that it is scoped by projectID, closing the same IDOR-on-write gap +// SetDomainTemplate guards against. +func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) { + s, ctx := newStore(t) + dom := seedDomain(t, s, ctx) + + const key = "CNAME admin.example.com." + if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil { + t.Fatal(err) + } + // Повторное добавление того же ключа не должно падать на PK-конфликте. + if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil { + t.Fatalf("second add must be a no-op, got %v", err) + } + keys, err := s.ListCustomKeys(ctx, dom.ID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0] != key { + t.Fatalf("expected exactly [%q], got %+v", key, keys) + } + + // Чужой проект не может пометить этот домен. + if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil { + t.Fatal("expected an error when marking a domain from another project") + } +} + +// TestCustomRecordsDeleteAndCascade verifies DeleteCustomRecord removes the +// mark, and that deleting the domain cascades to remove any remaining marks +// (domain_custom_records.domain_id REFERENCES domains(id) ON DELETE CASCADE). +func TestCustomRecordsDeleteAndCascade(t *testing.T) { + s, ctx := newStore(t) + dom := seedDomain(t, s, ctx) + + const key = "CNAME admin.example.com." + if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil { + t.Fatal(err) + } + if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil { + t.Fatal(err) + } + keys, err := s.ListCustomKeys(ctx, dom.ID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Fatalf("expected no keys after delete, got %+v", keys) + } + + // Каскад: удаление домена уносит его пометки. + if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil { + t.Fatal(err) + } + if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil { + t.Fatal(err) + } + keys, err = s.ListCustomKeys(ctx, dom.ID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 0 { + t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index e2084e1..612fd9f 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -245,6 +245,34 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid. return domainFromDB(d), nil } +// AddCustomRecord marks an RRset (identified by its diff key, "TYPE name.") +// as deliberately kept outside the template for this domain. Idempotent. +// The scoped GetDomain first ensures the domain belongs to projectID — +// otherwise a foreign domain could be marked (IDOR-on-write), same guard +// SetDomainTemplate uses for templates. +func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error { + if _, err := s.GetDomain(ctx, domainID, projectID); err != nil { + return err + } + return s.q.AddCustomRecord(ctx, db.AddCustomRecordParams{DomainID: domainID, RecordKey: key}) +} + +// DeleteCustomRecord removes the mark, returning the record to the regular +// diff. Scoped by projectID for the same reason as AddCustomRecord. +func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error { + if _, err := s.GetDomain(ctx, domainID, projectID); err != nil { + return err + } + return s.q.DeleteCustomRecord(ctx, db.DeleteCustomRecordParams{DomainID: domainID, RecordKey: key}) +} + +// ListCustomKeys returns the marked record keys of a domain. Not scoped by +// project on purpose: the only caller is LoadDomain, which has already +// resolved the domain by (id, projectID). +func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) { + return s.q.ListCustomKeys(ctx, domainID) +} + // GetDomainStatus returns the last known check status for a domain (Фаза 3 // scheduler/checker). Callers scope access to the domain themselves (e.g. // via a prior GetDomain) — this lookup is by primary key alone.