Files
dns-autoresolver/internal/service/service.go
T
vasyansk 9ccb304d2e feat(api): read zone records without template + snapshot-to-template
LoadDomain requires a template, so a zone without one could never be
viewed or snapshotted. Adds a template-free path: store.LoadZone /
service.ZoneRef / DomainService.ZoneRecords read a zone's live records
straight from the provider (no diff, no template). GET
/domains/{did}/records exposes read-only viewing; POST
/domains/{did}/template-from-zone snapshots only managed record types
(NS/SOA excluded) into a new template and auto-attaches it to the domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
2026-07-05 12:00:27 +07:00

134 lines
4.2 KiB
Go

package service
import (
"context"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// DomainRef is the minimal data the service needs about a domain.
type DomainRef struct {
ZoneID string
Provider string
SecretEnc string
Template dto.TemplateDoc
}
// ZoneRef is the provider-access subset of a domain, without a template —
// enough to read a zone's live records.
type ZoneRef struct {
ZoneID string
Provider string
SecretEnc string
}
type Loader interface {
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
}
type Recorder interface {
SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error
}
type ApplyRequest struct {
ApplyUpdates bool
ApplyPrunes bool
}
type DomainService struct {
loader Loader
rec Recorder
reg *registry.Registry
cipher *crypto.Cipher
}
func New(loader Loader, rec Recorder, reg *registry.Registry, cipher *crypto.Cipher) *DomainService {
return &DomainService{loader: loader, rec: rec, reg: reg, cipher: cipher}
}
// resolve loads the domain, its provider and decrypted credentials, and computes the diff.
// projectID scopes the lookup so a domainID belonging to another tenant's
// project can never be resolved here (closes IDOR).
func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UUID) (provider.Provider, provider.Credentials, DomainRef, diff.Changeset, error) {
ref, err := s.loader.LoadDomain(ctx, projectID, domainID)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
creds := provider.Credentials{Secret: string(secret)}
actual, err := p.GetRecords(ctx, creds, ref.ZoneID)
if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
}
cs := diff.Diff(ref.Template.ToModel(), actual)
return p, creds, ref, cs, nil
}
// Check computes and records the diff between template and zone.
func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) {
_, _, _, cs, err := s.resolve(ctx, projectID, domainID)
if err != nil {
return diff.Changeset{}, err
}
if err := s.rec.SaveCheckRun(ctx, domainID, cs); err != nil {
return diff.Changeset{}, err
}
return cs, nil
}
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required. Used for read-only zone viewing and
// as the source for a snapshot template.
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
if err != nil {
return nil, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, err
}
return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
}
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
if err != nil {
return diff.Changeset{}, err
}
var toApply []diff.RecordDiff
if req.ApplyUpdates {
toApply = append(toApply, cs.Updates()...)
}
if req.ApplyPrunes {
toApply = append(toApply, cs.Prunes()...)
}
applied := diff.Changeset{Diffs: toApply}
if len(toApply) > 0 {
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
return diff.Changeset{}, err
}
}
return applied, nil
}