rdev/internal/adapter/notify/provisioner.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

329 lines
11 KiB
Go

package notify
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Provisioner implements port.NotifyProvisioner using the notify admin API.
// Each project gets an isolated sending host (mail.{slug}.{baseDomain}),
// a Resend domain with DKIM/SPF DNS records, and a dedicated send key.
type Provisioner struct {
client notifyAdminAPI
resend resendAPI // nil when ResendAPIKey not configured
resendAPIKey string // passed to createProvider; kept separate from resend for interface compatibility
dns port.DNSProvider // nil when Cloudflare not configured
baseDomain string // e.g., "threesix.ai"
logger *slog.Logger
}
// Config holds configuration for the notify provisioner.
type Config struct {
BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai")
AdminKey string // Required: admin API key (notify_admin_...)
ResendAPIKey string // Optional: Resend API key for per-project domain provisioning
BaseDomain string // Base domain for per-project hosts (default: "threesix.ai")
}
// NewProvisioner creates a new notify provisioner.
func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Provisioner {
baseDomain := cfg.BaseDomain
if baseDomain == "" {
baseDomain = "threesix.ai"
}
p := &Provisioner{
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
dns: dns,
baseDomain: baseDomain,
logger: logger,
}
if cfg.ResendAPIKey != "" {
p.resend = newResendClient(cfg.ResendAPIKey)
p.resendAPIKey = cfg.ResendAPIKey
}
return p
}
// CreateProjectNotify provisions a per-project notify host, Resend domain, DNS records,
// and notify account with send key.
//
// Steps:
// 1. Create notify host mail.{slug}.{baseDomain}
// 2. Add Resend provider to the host (skipped if ResendAPIKey not configured)
// 3. Register from-address noreply@mail.{slug}.{baseDomain}
// 4. Create notify account "project-{projectID}"
// 5. Create send key for the account
// 6. Grant the account access to the host (non-fatal)
// 7. Create Resend domain (non-fatal — skipped if ResendAPIKey not configured)
// 8. Add DNS records via Cloudflare (non-fatal — skipped if DNS not configured)
// 9. Fire-and-forget async domain verification
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error) {
host := "mail." + slug + "." + p.baseDomain
from := "noreply@" + host
accountName := "project-" + projectID
// 1. Create notify host
if err := p.client.createHost(ctx, host, "failover"); err != nil {
return nil, fmt.Errorf("notify: create host %s for project %s: %w", host, projectID, err)
}
// 2. Add Resend provider to the host (only when Resend is configured)
if p.resend != nil {
if err := p.client.createProvider(ctx, host, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", host, projectID, err)
}
}
// 3. Register from-address
if err := p.client.createFromAddress(ctx, host, from, slug); err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", from, projectID, err)
}
// 4. Create account
acct, err := p.client.createAccount(ctx, accountName)
if err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
}
// 5. Create send key
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
if err != nil {
p.bestEffortDeleteAccount(ctx, acct.ID, projectID)
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
}
// 6. Grant host access (non-fatal — log warn and continue)
if err := p.client.grantHostAccess(ctx, host, acct.ID); err != nil {
p.logger.Warn("failed to grant notify host access",
"host", host,
"account_id", acct.ID,
"project_id", projectID,
"error", err,
)
}
// 7. Create Resend domain (non-fatal — project still usable, email won't send until fixed)
var resendDomainID string
var dnsRecords []resendDNSRecord
if p.resend != nil {
var resendErr error
resendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, host, "us-east-1")
if resendErr != nil {
p.logger.Warn("failed to create resend domain — email delivery will not work until resolved",
"host", host,
"project_id", projectID,
"error", resendErr,
)
} else {
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID)
}
}
// 8. Add DNS records for DKIM/SPF (non-fatal).
// Resend returns record names relative to the registered domain; build FQDNs for Cloudflare.
// Cloudflare's normalizeName handles FQDNs ending in the zone name correctly.
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
fqdn := rec.Name + "." + host
dnsRec := domain.DNSRecord{
Type: rec.Record,
Name: fqdn,
Content: rec.Value,
TTL: 1,
}
if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn,
"record", rec.Record,
"project_id", projectID,
"error", upsertErr,
)
}
}
}
// 9. Fire-and-forget async domain verification
if p.resend != nil && resendDomainID != "" {
go func() {
verifyCtx := context.WithoutCancel(ctx)
if err := p.resend.verifyDomain(verifyCtx, resendDomainID); err != nil {
p.logger.Warn("async resend domain verification failed",
"domain_id", resendDomainID,
"host", host,
"error", err,
)
}
}()
}
p.logger.Info("notify provisioned",
"project_id", projectID,
"host", host,
"resend_domain_id", resendDomainID,
)
return &domain.NotifyCredentials{
ProjectID: projectID,
AccountID: acct.ID,
APIKey: key.Key,
Host: host,
From: from,
ResendDomainID: resendDomainID,
CreatedAt: time.Now(),
}, nil
}
// DeleteProjectNotify removes all notify resources for a project.
// Failures are logged as warnings — cleanup continues regardless.
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error {
host := "mail." + slug + "." + p.baseDomain
// 1. Delete notify account (cascades keys + host grants)
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
p.logger.Warn("failed to find notify account during deletion",
"project_id", projectID,
"error", err,
)
} else if acct != nil {
if err := p.client.deleteAccount(ctx, acct.ID); err != nil {
p.logger.Warn("failed to delete notify account",
"account_id", acct.ID,
"project_id", projectID,
"error", err,
)
}
}
// 2. Delete notify host
if err := p.client.deleteHost(ctx, host); err != nil {
p.logger.Warn("failed to delete notify host",
"host", host,
"project_id", projectID,
"error", err,
)
}
// 3. Delete Resend domain
if p.resend != nil && resendDomainID != "" {
if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil {
p.logger.Warn("failed to delete resend domain",
"domain_id", resendDomainID,
"project_id", projectID,
"error", err,
)
}
}
// 4. Delete Cloudflare DNS records for DKIM/SPF.
// Names follow Resend's standard format:
// DKIM: resend._domainkey.{host}
// SPF MX: send.{host}
// SPF TXT: send.{host}
// If Resend changes their record naming, manual cleanup may be needed.
if p.dns != nil {
dkimName := "resend._domainkey." + host
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
p.logger.Warn("failed to delete DKIM DNS record",
"name", dkimName,
"project_id", projectID,
"error", err,
)
}
spfSendName := "send." + host
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
p.logger.Warn("failed to delete SPF MX DNS record",
"name", spfSendName,
"project_id", projectID,
"error", err,
)
}
if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil {
p.logger.Warn("failed to delete SPF TXT DNS record",
"name", spfSendName,
"project_id", projectID,
"error", err,
)
}
}
p.logger.Info("notify resources deleted", "project_id", projectID, "host", host)
return nil
}
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
// Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not
// recoverable after provisioning. Use this method solely to check whether provisioning
// has already occurred (non-nil return = already provisioned).
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err)
}
if acct == nil {
return nil, nil
}
return &domain.NotifyCredentials{
ProjectID: projectID,
AccountID: acct.ID,
CreatedAt: acct.CreatedAt,
}, nil
}
// TestConnection verifies the notify admin API is reachable.
func (p *Provisioner) TestConnection(ctx context.Context) error {
_, err := p.client.listAccounts(ctx)
if err != nil {
return fmt.Errorf("notify admin API unreachable: %w", err)
}
return nil
}
// findAccountByProject looks up the account named "project-{projectID}".
func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string) (*accountResponse, error) {
accounts, err := p.client.listAccounts(ctx)
if err != nil {
return nil, err
}
targetName := "project-" + projectID
for i := range accounts {
if accounts[i].Name == targetName {
return &accounts[i], nil
}
}
return nil, nil
}
// bestEffortDeleteHost deletes the notify host, logging on failure.
func (p *Provisioner) bestEffortDeleteHost(ctx context.Context, host, projectID string) {
if err := p.client.deleteHost(ctx, host); err != nil {
p.logger.Warn("failed to clean up notify host after provisioning failure",
"host", host,
"project_id", projectID,
"error", err,
)
}
}
// bestEffortDeleteAccount deletes the notify account, logging on failure.
func (p *Provisioner) bestEffortDeleteAccount(ctx context.Context, accountID, projectID string) {
if err := p.client.deleteAccount(ctx, accountID); err != nil {
p.logger.Warn("failed to clean up notify account after provisioning failure",
"account_id", accountID,
"project_id", projectID,
"error", err,
)
}
}