diff --git a/pkg/coredata/cached_certificate.go b/pkg/coredata/cached_certificate.go index 91e4e7781..258074937 100644 --- a/pkg/coredata/cached_certificate.go +++ b/pkg/coredata/cached_certificate.go @@ -39,7 +39,7 @@ type ( CertificateChain *string `db:"certificate_chain"` ExpiresAt time.Time `db:"expires_at"` CachedAt time.Time `db:"cached_at"` - CustomDomainID gid.GID `db:"custom_domain_id"` + CertificateID gid.GID `db:"certificate_id"` } CachedCertificates []*CachedCertificate @@ -54,7 +54,7 @@ SELECT certificate_chain, expires_at, cached_at, - custom_domain_id + certificate_id FROM cached_certificates WHERE @@ -91,7 +91,7 @@ INSERT INTO cached_certificates ( certificate_chain, expires_at, cached_at, - custom_domain_id + certificate_id ) VALUES ( @domain, @certificate_pem, @@ -99,7 +99,7 @@ INSERT INTO cached_certificates ( @certificate_chain, @expires_at, @cached_at, - @custom_domain_id + @certificate_id ) ON CONFLICT (domain) DO UPDATE SET certificate_pem = EXCLUDED.certificate_pem, @@ -107,7 +107,7 @@ ON CONFLICT (domain) DO UPDATE SET certificate_chain = EXCLUDED.certificate_chain, expires_at = EXCLUDED.expires_at, cached_at = NOW(), - custom_domain_id = EXCLUDED.custom_domain_id + certificate_id = EXCLUDED.certificate_id ` args := pgx.NamedArgs{ @@ -117,7 +117,7 @@ ON CONFLICT (domain) DO UPDATE SET "certificate_chain": cc.CertificateChain, "expires_at": cc.ExpiresAt, "cached_at": cc.CachedAt, - "custom_domain_id": cc.CustomDomainID, + "certificate_id": cc.CertificateID, } _, err := conn.Exec(ctx, q, args) @@ -170,36 +170,36 @@ WHERE return nil } -func (cc *CachedCertificate) RefreshFromDomain(ctx context.Context, conn pg.Querier, domain *CustomDomain, encryptionKey cipher.EncryptionKey) error { - if domain.SSLCertificate == nil { - return fmt.Errorf("domain has no parsed certificate") +func (cc *CachedCertificate) RefreshFromCertificate(ctx context.Context, conn pg.Querier, certificate *Certificate, encryptionKey cipher.EncryptionKey) error { + if certificate.SSLCertificate == nil { + return fmt.Errorf("certificate has no parsed certificate") } - if len(domain.SSLCertificatePEM) == 0 { - return fmt.Errorf("domain has no certificate PEM") + if len(certificate.SSLCertificatePEM) == 0 { + return fmt.Errorf("certificate has no certificate PEM") } - privateKeyPEM, err := domain.DecryptPrivateKey(encryptionKey) + privateKeyPEM, err := certificate.DecryptPrivateKey(encryptionKey) if err != nil { return fmt.Errorf("cannot decrypt private key: %w", err) } if len(privateKeyPEM) == 0 { - return fmt.Errorf("domain has no private key PEM") + return fmt.Errorf("certificate has no private key PEM") } - if domain.SSLExpiresAt == nil { - return fmt.Errorf("domain certificate has no expiry date") + if certificate.SSLExpiresAt == nil { + return fmt.Errorf("certificate has no expiry date") } cache := &CachedCertificate{ - Domain: domain.Domain, - CertificatePEM: string(domain.SSLCertificatePEM), + Domain: certificate.Hostname, + CertificatePEM: string(certificate.SSLCertificatePEM), PrivateKeyPEM: string(privateKeyPEM), - CertificateChain: domain.SSLCertificateChain, - ExpiresAt: *domain.SSLExpiresAt, + CertificateChain: certificate.SSLCertificateChain, + ExpiresAt: *certificate.SSLExpiresAt, CachedAt: time.Now(), - CustomDomainID: domain.ID, + CertificateID: certificate.ID, } return cache.Upsert(ctx, conn) diff --git a/pkg/coredata/certificate.go b/pkg/coredata/certificate.go new file mode 100644 index 000000000..17f9861eb --- /dev/null +++ b/pkg/coredata/certificate.go @@ -0,0 +1,915 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/crypto/cipher" + "go.probo.inc/probo/pkg/gid" +) + +type ( + // Certificate is a generic TLS certificate together with its ACME + // provisioning lifecycle for a single hostname. It carries no knowledge of + // the resource it protects; the hostname is globally unique. + Certificate struct { + ID gid.GID `db:"id"` + Hostname string `db:"hostname"` + HTTPChallengeToken *string `db:"http_challenge_token"` + HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"` + HTTPChallengeURL *string `db:"http_challenge_url"` + HTTPOrderURL *string `db:"http_order_url"` + SSLCertificate *tls.Certificate `db:"-"` + SSLCertificatePEM []byte `db:"ssl_certificate"` + EncryptedSSLPrivateKey []byte `db:"encrypted_ssl_private_key"` + SSLCertificateChain *string `db:"ssl_certificate_chain"` + Status CertificateStatus `db:"status"` + SSLExpiresAt *time.Time `db:"ssl_expires_at"` + SSLRetryCount int `db:"ssl_retry_count"` + SSLLastAttemptAt *time.Time `db:"ssl_last_attempt_at"` + ProvisioningError *string `db:"provisioning_error"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + Certificates []*Certificate +) + +func NewCertificate( + tenantID gid.TenantID, + hostname string, +) *Certificate { + now := time.Now() + + return &Certificate{ + ID: gid.New(tenantID, CertificateEntityType), + Hostname: hostname, + Status: CertificateStatusPending, + CreatedAt: now, + UpdatedAt: now, + } +} + +func (c *Certificate) DecryptPrivateKey(encryptionKey cipher.EncryptionKey) ([]byte, error) { + if len(c.EncryptedSSLPrivateKey) == 0 { + return nil, nil + } + + decrypted, err := cipher.Decrypt(c.EncryptedSSLPrivateKey, encryptionKey) + if err != nil { + return nil, fmt.Errorf("cannot decrypt SSL private key: %w", err) + } + + return decrypted, nil +} + +func (c *Certificate) EncryptPrivateKey(privateKeyPEM []byte, encryptionKey cipher.EncryptionKey) error { + if len(privateKeyPEM) == 0 { + c.EncryptedSSLPrivateKey = nil + return nil + } + + encrypted, err := cipher.Encrypt(privateKeyPEM, encryptionKey) + if err != nil { + return fmt.Errorf("cannot encrypt SSL private key: %w", err) + } + + c.EncryptedSSLPrivateKey = encrypted + + return nil +} + +func (c *Certificate) ParseCertificate(encryptionKey cipher.EncryptionKey) error { + if len(c.SSLCertificatePEM) == 0 { + return fmt.Errorf("no certificate PEM data") + } + + privateKeyPEM, err := c.DecryptPrivateKey(encryptionKey) + if err != nil { + return fmt.Errorf("cannot decrypt private key: %w", err) + } + + if len(privateKeyPEM) == 0 { + return fmt.Errorf("no private key data") + } + + fullCertPEM := string(c.SSLCertificatePEM) + if c.SSLCertificateChain != nil && *c.SSLCertificateChain != "" { + fullCertPEM += "\n" + *c.SSLCertificateChain + } + + tlsCert, err := tls.X509KeyPair([]byte(fullCertPEM), privateKeyPEM) + if err != nil { + return fmt.Errorf("cannot parse certificate and key: %w", err) + } + + c.SSLCertificate = &tlsCert + + return nil +} + +func (c *Certificate) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + certificateID gid.GID, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND id = @id +LIMIT 1 +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"id": certificateID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificate: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} + +func (c *Certificate) LoadByIDForUpdateSkipLocked( + ctx context.Context, + conn pg.Tx, + scope Scoper, + certificateID gid.GID, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND id = @id +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"id": certificateID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificate for update: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} + +func (c *Certificate) LoadByHostname( + ctx context.Context, + conn pg.Querier, + scope Scoper, + hostname string, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND hostname = @hostname +LIMIT 1 +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"hostname": hostname} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificate: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} + +func (c *Certificate) LoadByHTTPChallengeToken( + ctx context.Context, + conn pg.Querier, + scope Scoper, + token string, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND http_challenge_token = @token +LIMIT 1 +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"token": token} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificate: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} + +func (certificates *Certificates) LoadByIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + ids []gid.GID, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND id = ANY(@ids) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"ids": ids} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificates: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect certificates: %w", err) + } + + *certificates = result + + return nil +} + +func (c *Certificate) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + var encryptedKey []byte + if len(c.EncryptedSSLPrivateKey) > 0 { + encryptedKey = c.EncryptedSSLPrivateKey + } + + q := ` +INSERT INTO certificates ( + id, + tenant_id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @hostname, + @http_challenge_token, + @http_challenge_key_auth, + @http_challenge_url, + @http_order_url, + @ssl_certificate, + @encrypted_ssl_private_key, + @ssl_certificate_chain, + @status, + @ssl_expires_at, + @ssl_retry_count, + @ssl_last_attempt_at, + @provisioning_error, + @created_at, + @updated_at +) +` + + args := pgx.NamedArgs{ + "id": c.ID, + "tenant_id": scope.GetTenantID(), + "hostname": c.Hostname, + "http_challenge_token": c.HTTPChallengeToken, + "http_challenge_key_auth": c.HTTPChallengeKeyAuth, + "http_challenge_url": c.HTTPChallengeURL, + "http_order_url": c.HTTPOrderURL, + "ssl_certificate": c.SSLCertificatePEM, + "encrypted_ssl_private_key": encryptedKey, + "ssl_certificate_chain": c.SSLCertificateChain, + "status": c.Status, + "ssl_expires_at": c.SSLExpiresAt, + "ssl_retry_count": c.SSLRetryCount, + "ssl_last_attempt_at": c.SSLLastAttemptAt, + "provisioning_error": c.ProvisioningError, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "certificates_hostname_key" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert certificate: %w", err) + } + + c.EncryptedSSLPrivateKey = encryptedKey + + return nil +} + +func (c *Certificate) Update( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + var encryptedKey []byte + if len(c.EncryptedSSLPrivateKey) > 0 { + encryptedKey = c.EncryptedSSLPrivateKey + } + + q := ` +UPDATE + certificates +SET + hostname = @hostname, + http_challenge_token = @http_challenge_token, + http_challenge_key_auth = @http_challenge_key_auth, + http_challenge_url = @http_challenge_url, + http_order_url = @http_order_url, + ssl_certificate = @ssl_certificate, + encrypted_ssl_private_key = @encrypted_ssl_private_key, + ssl_certificate_chain = @ssl_certificate_chain, + status = @status, + ssl_expires_at = @ssl_expires_at, + ssl_retry_count = @ssl_retry_count, + ssl_last_attempt_at = @ssl_last_attempt_at, + provisioning_error = @provisioning_error, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "id": c.ID, + "hostname": c.Hostname, + "http_challenge_token": c.HTTPChallengeToken, + "http_challenge_key_auth": c.HTTPChallengeKeyAuth, + "http_challenge_url": c.HTTPChallengeURL, + "http_order_url": c.HTTPOrderURL, + "ssl_certificate": c.SSLCertificatePEM, + "encrypted_ssl_private_key": encryptedKey, + "ssl_certificate_chain": c.SSLCertificateChain, + "status": c.Status, + "ssl_expires_at": c.SSLExpiresAt, + "ssl_retry_count": c.SSLRetryCount, + "ssl_last_attempt_at": c.SSLLastAttemptAt, + "provisioning_error": c.ProvisioningError, + "updated_at": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update certificate: %w", err) + } + + c.EncryptedSSLPrivateKey = encryptedKey + + return nil +} + +func (c *Certificate) Delete( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM + certificates +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"id": c.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete certificate: %w", err) + } + + return nil +} + +func (certificates *Certificates) ListForRenewal( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND status = @status + AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days' +ORDER BY + ssl_expires_at ASC +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"status": string(CertificateStatusActive)} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificates for renewal: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect certificates: %w", err) + } + + *certificates = result + + return nil +} + +func (certificates *Certificates) ListWithPendingHTTPChallenges( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND status = ANY(@statuses) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "statuses": []string{ + string(CertificateStatusPending), + string(CertificateStatusProvisioning), + string(CertificateStatusRenewing), + }, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query certificates with pending challenges: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect certificates: %w", err) + } + + *certificates = result + + return nil +} + +func (certificates *Certificates) LoadActive( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND status = @status + AND ssl_certificate IS NOT NULL +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"status": string(CertificateStatusActive)} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query active certificates: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect certificates: %w", err) + } + + *certificates = result + + return nil +} + +func (certificates *Certificates) ListStaleProvisioning( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + %s + AND ( + (status IN (@provisioning_status, @renewing_status) AND updated_at < CURRENT_TIMESTAMP - INTERVAL '4 hours') + OR + (ssl_retry_count > 0 AND ssl_last_attempt_at < CURRENT_TIMESTAMP - INTERVAL '24 hours') + ) + AND status != @failed_status + AND status != @active_status +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "provisioning_status": string(CertificateStatusProvisioning), + "renewing_status": string(CertificateStatusRenewing), + "failed_status": string(CertificateStatusFailed), + "active_status": string(CertificateStatusActive), + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query stale provisioning certificates: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate]) + if err != nil { + return fmt.Errorf("cannot collect stale provisioning certificates: %w", err) + } + + *certificates = result + + return nil +} + +func (c *Certificate) LoadNextForProvisioningForUpdateSkipLocked( + ctx context.Context, + tx pg.Tx, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + status = ANY(@statuses) +ORDER BY + updated_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + + rows, err := tx.Query( + ctx, + q, + pgx.StrictNamedArgs{ + "statuses": []string{ + string(CertificateStatusPending), + string(CertificateStatusProvisioning), + string(CertificateStatusRenewing), + }, + }, + ) + if err != nil { + return fmt.Errorf("cannot query certificate provisioning queue: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} + +func (c *Certificate) LoadNextForRenewalForUpdateSkipLocked( + ctx context.Context, + tx pg.Tx, +) error { + q := ` +SELECT + id, + hostname, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + provisioning_error, + created_at, + updated_at +FROM + certificates +WHERE + status = @status + AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days' +ORDER BY + ssl_expires_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + + rows, err := tx.Query( + ctx, + q, + pgx.StrictNamedArgs{"status": string(CertificateStatusActive)}, + ) + if err != nil { + return fmt.Errorf("cannot query certificate renewal queue: %w", err) + } + + certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect certificate: %w", err) + } + + *c = certificate + + return nil +} diff --git a/pkg/coredata/certificate_status.go b/pkg/coredata/certificate_status.go new file mode 100644 index 000000000..5921dc73e --- /dev/null +++ b/pkg/coredata/certificate_status.go @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" +) + +type CertificateStatus string + +const ( + CertificateStatusPending CertificateStatus = "PENDING" + CertificateStatusProvisioning CertificateStatus = "PROVISIONING" + CertificateStatusActive CertificateStatus = "ACTIVE" + CertificateStatusRenewing CertificateStatus = "RENEWING" + CertificateStatusExpired CertificateStatus = "EXPIRED" + CertificateStatusFailed CertificateStatus = "FAILED" +) + +var ( + _ fmt.Stringer = CertificateStatus("") + _ encoding.TextMarshaler = CertificateStatus("") + _ encoding.TextUnmarshaler = (*CertificateStatus)(nil) +) + +func CertificateStatuses() []CertificateStatus { + return []CertificateStatus{ + CertificateStatusPending, + CertificateStatusProvisioning, + CertificateStatusActive, + CertificateStatusRenewing, + CertificateStatusExpired, + CertificateStatusFailed, + } +} + +func (v CertificateStatus) IsValid() bool { + switch v { + case + CertificateStatusPending, + CertificateStatusProvisioning, + CertificateStatusActive, + CertificateStatusRenewing, + CertificateStatusExpired, + CertificateStatusFailed: + return true + } + + return false +} + +func (v CertificateStatus) String() string { + return string(v) +} + +func (v CertificateStatus) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *CertificateStatus) UnmarshalText(text []byte) error { + val := CertificateStatus(text) + if !val.IsValid() { + return fmt.Errorf("invalid CertificateStatus value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/custom_domain.go b/pkg/coredata/custom_domain.go index dbb3c97ab..e835aafad 100644 --- a/pkg/coredata/custom_domain.go +++ b/pkg/coredata/custom_domain.go @@ -22,55 +22,52 @@ package coredata import ( "context" - "crypto/tls" "errors" "fmt" "maps" + "strconv" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) type ( + // CustomDomain is a domain owned by an organization used to serve its + // compliance portal. Its TLS certificate lifecycle is owned by the generic + // certificates table, referenced through CertificateID. CustomDomain struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - Domain string `db:"domain"` - HTTPChallengeToken *string `db:"http_challenge_token"` - HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"` - HTTPChallengeURL *string `db:"http_challenge_url"` - HTTPOrderURL *string `db:"http_order_url"` - SSLCertificate *tls.Certificate `db:"-"` - SSLCertificatePEM []byte `db:"ssl_certificate"` - EncryptedSSLPrivateKey []byte `db:"encrypted_ssl_private_key"` - SSLCertificateChain *string `db:"ssl_certificate_chain"` - SSLStatus CustomDomainSSLStatus `db:"ssl_status"` - SSLExpiresAt *time.Time `db:"ssl_expires_at"` - SSLRetryCount int `db:"ssl_retry_count"` - SSLLastAttemptAt *time.Time `db:"ssl_last_attempt_at"` - ProvisioningError *string `db:"provisioning_error"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + Domain string `db:"domain"` + Managed bool `db:"managed"` + CertificateID *gid.GID `db:"certificate_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } CustomDomains []*CustomDomain ) -func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain { +func NewCustomDomain( + tenantID gid.TenantID, + organizationID gid.GID, + domain string, + managed bool, +) *CustomDomain { now := time.Now() return &CustomDomain{ - ID: gid.New(tenantID, CustomDomainEntityType), - SSLStatus: CustomDomainSSLStatusPending, - Domain: domain, - CreatedAt: now, - UpdatedAt: now, + ID: gid.New(tenantID, CustomDomainEntityType), + OrganizationID: organizationID, + Domain: domain, + Managed: managed, + CreatedAt: now, + UpdatedAt: now, } } @@ -80,7 +77,7 @@ func (cd *CustomDomain) AuthorizationAttributes( conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { - q := `SELECT id, organization_id FROM custom_domains WHERE id = ANY(@resource_ids::text[])` + q := `SELECT id, organization_id, managed FROM custom_domains WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, @@ -96,14 +93,18 @@ func (cd *CustomDomain) AuthorizationAttributes( attrsByID := make(policy.AttributesByID) for rows.Next() { - var id, organizationID gid.GID + var ( + id, organizationID gid.GID + managed bool + ) - if err := rows.Scan(&id, &organizationID); err != nil { + if err := rows.Scan(&id, &organizationID, &managed); err != nil { return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) } attrsByID[id] = policy.Attributes{ "organization_id": organizationID.String(), + "managed": strconv.FormatBool(managed), } } @@ -127,64 +128,6 @@ func (cd *CustomDomain) CursorKey(field CustomDomainOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (cd *CustomDomain) DecryptPrivateKey(encryptionKey cipher.EncryptionKey) ([]byte, error) { - if len(cd.EncryptedSSLPrivateKey) == 0 { - return nil, nil - } - - decrypted, err := cipher.Decrypt(cd.EncryptedSSLPrivateKey, encryptionKey) - if err != nil { - return nil, fmt.Errorf("cannot decrypt SSL private key: %w", err) - } - - return decrypted, nil -} - -func (cd *CustomDomain) EncryptPrivateKey(privateKeyPEM []byte, encryptionKey cipher.EncryptionKey) error { - if len(privateKeyPEM) == 0 { - cd.EncryptedSSLPrivateKey = nil - return nil - } - - encrypted, err := cipher.Encrypt(privateKeyPEM, encryptionKey) - if err != nil { - return fmt.Errorf("cannot encrypt SSL private key: %w", err) - } - - cd.EncryptedSSLPrivateKey = encrypted - - return nil -} - -func (cd *CustomDomain) ParseCertificate(encryptionKey cipher.EncryptionKey) error { - if len(cd.SSLCertificatePEM) == 0 { - return fmt.Errorf("no certificate PEM data") - } - - privateKeyPEM, err := cd.DecryptPrivateKey(encryptionKey) - if err != nil { - return fmt.Errorf("cannot decrypt private key: %w", err) - } - - if len(privateKeyPEM) == 0 { - return fmt.Errorf("no private key data") - } - - fullCertPEM := string(cd.SSLCertificatePEM) - if cd.SSLCertificateChain != nil && *cd.SSLCertificateChain != "" { - fullCertPEM += "\n" + *cd.SSLCertificateChain - } - - tlsCert, err := tls.X509KeyPair([]byte(fullCertPEM), privateKeyPEM) - if err != nil { - return fmt.Errorf("cannot parse certificate and key: %w", err) - } - - cd.SSLCertificate = &tlsCert - - return nil -} - func (cd *CustomDomain) LoadByID( ctx context.Context, conn pg.Querier, @@ -196,18 +139,8 @@ SELECT id, organization_id, domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, + managed, + certificate_id, created_at, updated_at FROM @@ -242,63 +175,6 @@ LIMIT 1 return nil } -func (cd *CustomDomain) LoadByIDForUpdateSkipLocked( - ctx context.Context, - conn pg.Tx, - scope Scoper, - domainID gid.GID, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND id = @id -LIMIT 1 -FOR UPDATE SKIP LOCKED -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{"id": domainID} - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query custom domain for update: %w", err) - } - - customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrResourceNotFound - } - - return fmt.Errorf("cannot collect custom domain: %w", err) - } - - *cd = customDomain - - return nil -} - func (cd *CustomDomain) LoadByDomain( ctx context.Context, conn pg.Querier, @@ -310,18 +186,8 @@ SELECT id, organization_id, domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, + managed, + certificate_id, created_at, updated_at FROM @@ -356,59 +222,44 @@ LIMIT 1 return nil } -func (cd *CustomDomain) LoadByOrganizationID( +func (domains *CustomDomains) LoadByIDs( ctx context.Context, conn pg.Querier, scope Scoper, - organizationID gid.GID, + ids []gid.GID, ) error { q := ` SELECT id, organization_id, domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, + managed, + certificate_id, created_at, updated_at FROM custom_domains WHERE %s - AND organization_id = @organization_id -LIMIT 1 + AND id = ANY(@ids) ` q = fmt.Sprintf(q, scope.SQLFragment()) - args := pgx.NamedArgs{"organization_id": organizationID} + args := pgx.NamedArgs{"ids": ids} maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query custom domain: %w", err) + return fmt.Errorf("cannot query custom domains: %w", err) } - customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain]) + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain]) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrResourceNotFound - } - - return fmt.Errorf("cannot collect custom domain: %w", err) + return fmt.Errorf("cannot collect custom domains: %w", err) } - *cd = customDomain + *domains = result return nil } @@ -417,31 +268,15 @@ func (cd *CustomDomain) Insert( ctx context.Context, conn pg.Tx, scope Scoper, - encryptionKey cipher.EncryptionKey, ) error { - var encryptedKey []byte - if len(cd.EncryptedSSLPrivateKey) > 0 { - encryptedKey = cd.EncryptedSSLPrivateKey - } - q := ` INSERT INTO custom_domains ( id, tenant_id, organization_id, domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, + managed, + certificate_id, created_at, updated_at ) VALUES ( @@ -449,42 +284,22 @@ INSERT INTO custom_domains ( @tenant_id, @organization_id, @domain, - @http_challenge_token, - @http_challenge_key_auth, - @http_challenge_url, - @http_order_url, - @ssl_certificate, - @encrypted_ssl_private_key, - @ssl_certificate_chain, - @ssl_status, - @ssl_expires_at, - @ssl_retry_count, - @ssl_last_attempt_at, - @provisioning_error, + @managed, + @certificate_id, @created_at, @updated_at ) ` args := pgx.NamedArgs{ - "id": cd.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": cd.OrganizationID, - "domain": cd.Domain, - "http_challenge_token": cd.HTTPChallengeToken, - "http_challenge_key_auth": cd.HTTPChallengeKeyAuth, - "http_challenge_url": cd.HTTPChallengeURL, - "http_order_url": cd.HTTPOrderURL, - "ssl_certificate": cd.SSLCertificatePEM, - "encrypted_ssl_private_key": encryptedKey, - "ssl_certificate_chain": cd.SSLCertificateChain, - "ssl_status": cd.SSLStatus, - "ssl_expires_at": cd.SSLExpiresAt, - "ssl_retry_count": cd.SSLRetryCount, - "ssl_last_attempt_at": cd.SSLLastAttemptAt, - "provisioning_error": cd.ProvisioningError, - "created_at": cd.CreatedAt, - "updated_at": cd.UpdatedAt, + "id": cd.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": cd.OrganizationID, + "domain": cd.Domain, + "managed": cd.Managed, + "certificate_id": cd.CertificateID, + "created_at": cd.CreatedAt, + "updated_at": cd.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -498,8 +313,6 @@ INSERT INTO custom_domains ( return fmt.Errorf("cannot insert custom domain: %w", err) } - cd.EncryptedSSLPrivateKey = encryptedKey - return nil } @@ -508,27 +321,13 @@ func (cd *CustomDomain) Update( conn pg.Tx, scope Scoper, ) error { - var encryptedKey []byte - if len(cd.EncryptedSSLPrivateKey) > 0 { - encryptedKey = cd.EncryptedSSLPrivateKey - } - q := ` UPDATE custom_domains SET - http_challenge_token = @http_challenge_token, - http_challenge_key_auth = @http_challenge_key_auth, - http_challenge_url = @http_challenge_url, - http_order_url = @http_order_url, - ssl_certificate = @ssl_certificate, - encrypted_ssl_private_key = @encrypted_ssl_private_key, - ssl_certificate_chain = @ssl_certificate_chain, - ssl_status = @ssl_status, - ssl_expires_at = @ssl_expires_at, - ssl_retry_count = @ssl_retry_count, - ssl_last_attempt_at = @ssl_last_attempt_at, - provisioning_error = @provisioning_error, + domain = @domain, + managed = @managed, + certificate_id = @certificate_id, updated_at = @updated_at WHERE %s @@ -538,20 +337,11 @@ WHERE q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.NamedArgs{ - "id": cd.ID, - "http_challenge_token": cd.HTTPChallengeToken, - "http_challenge_key_auth": cd.HTTPChallengeKeyAuth, - "http_challenge_url": cd.HTTPChallengeURL, - "http_order_url": cd.HTTPOrderURL, - "ssl_certificate": cd.SSLCertificatePEM, - "encrypted_ssl_private_key": encryptedKey, - "ssl_certificate_chain": cd.SSLCertificateChain, - "ssl_status": cd.SSLStatus, - "ssl_expires_at": cd.SSLExpiresAt, - "ssl_retry_count": cd.SSLRetryCount, - "ssl_last_attempt_at": cd.SSLLastAttemptAt, - "provisioning_error": cd.ProvisioningError, - "updated_at": time.Now(), + "id": cd.ID, + "domain": cd.Domain, + "managed": cd.Managed, + "certificate_id": cd.CertificateID, + "updated_at": time.Now(), } maps.Copy(args, scope.SQLArguments()) @@ -560,8 +350,6 @@ WHERE return fmt.Errorf("cannot update custom domain: %w", err) } - cd.EncryptedSSLPrivateKey = encryptedKey - return nil } @@ -589,281 +377,3 @@ WHERE return nil } - -func (cd *CustomDomain) LoadByHTTPChallengeToken( - ctx context.Context, - conn pg.Querier, - scope Scoper, - token string, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND http_challenge_token = @token -LIMIT 1 -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{"token": token} - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query custom domain: %w", err) - } - - customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain]) - if err != nil { - return fmt.Errorf("cannot collect custom domain: %w", err) - } - - *cd = customDomain - - return nil -} - -func (domains *CustomDomains) ListDomainsForRenewal( - ctx context.Context, - conn pg.Querier, - scope Scoper, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND ssl_status = @status - AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days' -ORDER BY - ssl_expires_at ASC -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{"status": string(CustomDomainSSLStatusActive)} - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query custom domains for renewal: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain]) - if err != nil { - return fmt.Errorf("cannot collect custom domains: %w", err) - } - - *domains = result - - return nil -} - -func (domains *CustomDomains) ListDomainsWithPendingHTTPChallenges( - ctx context.Context, - conn pg.Querier, - scope Scoper, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND ssl_status = ANY(@statuses) -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{ - "statuses": []string{ - string(CustomDomainSSLStatusPending), - string(CustomDomainSSLStatusProvisioning), - string(CustomDomainSSLStatusRenewing), - }, - } - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query custom domains with pending challenges: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain]) - if err != nil { - return fmt.Errorf("cannot collect custom domains: %w", err) - } - - *domains = result - - return nil -} - -func (domains *CustomDomains) LoadActiveCertificates( - ctx context.Context, - conn pg.Querier, - scope Scoper, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND ssl_status = @status - AND ssl_certificate IS NOT NULL -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{"status": string(CustomDomainSSLStatusActive)} - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query active certificates: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain]) - if err != nil { - return fmt.Errorf("cannot collect custom domains: %w", err) - } - - *domains = result - - return nil -} - -func (domains *CustomDomains) ListStaleProvisioningDomains( - ctx context.Context, - conn pg.Querier, - scope Scoper, -) error { - q := ` -SELECT - id, - organization_id, - domain, - http_challenge_token, - http_challenge_key_auth, - http_challenge_url, - http_order_url, - ssl_certificate, - encrypted_ssl_private_key, - ssl_certificate_chain, - ssl_status, - ssl_expires_at, - ssl_retry_count, - ssl_last_attempt_at, - provisioning_error, - created_at, - updated_at -FROM - custom_domains -WHERE - %s - AND ( - (ssl_status IN (@provisioning_status, @renewing_status) AND updated_at < CURRENT_TIMESTAMP - INTERVAL '4 hours') - OR - (ssl_retry_count > 0 AND ssl_last_attempt_at < CURRENT_TIMESTAMP - INTERVAL '24 hours') - ) - AND ssl_status != @failed_status - AND ssl_status != @active_status -` - - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{ - "provisioning_status": string(CustomDomainSSLStatusProvisioning), - "renewing_status": string(CustomDomainSSLStatusRenewing), - "failed_status": string(CustomDomainSSLStatusFailed), - "active_status": string(CustomDomainSSLStatusActive), - } - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query stale provisioning domains: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain]) - if err != nil { - return fmt.Errorf("cannot collect stale provisioning domains: %w", err) - } - - *domains = result - - return nil -} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 757b7e304..257adada1 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -92,7 +92,7 @@ const ( ElectronicSignatureEventEntityType uint16 = 60 EmailAttachmentEntityType uint16 = 61 ComplianceFrameworkEntityType uint16 = 62 - ComplianceExternalURLEntityType uint16 = 63 + ComplianceCustomLinkEntityType uint16 = 63 MailingListEntityType uint16 = 64 MailingListSubscriberEntityType uint16 = 65 MailingListUpdateEntityType uint16 = 66 @@ -135,6 +135,7 @@ const ( AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103 CompliancePortalCommitmentGroupEntityType uint16 = 104 CompliancePortalCommitmentEntityType uint16 = 105 + CertificateEntityType uint16 = 106 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -253,8 +254,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &EmailAttachment{ID: id}, true case ComplianceFrameworkEntityType: return &ComplianceFramework{ID: id}, true - case ComplianceExternalURLEntityType: - return &ComplianceExternalURL{ID: id}, true + case ComplianceCustomLinkEntityType: + return &ComplianceCustomLink{ID: id}, true case MailingListEntityType: return &MailingList{ID: id}, true case MailingListSubscriberEntityType: @@ -333,6 +334,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &CompliancePortalCommitmentGroup{ID: id}, true case CompliancePortalCommitmentEntityType: return &CompliancePortalCommitment{ID: id}, true + case CertificateEntityType: + return &Certificate{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260709T090905Z.sql b/pkg/coredata/migrations/20260709T090905Z.sql new file mode 100644 index 000000000..a19c542ff --- /dev/null +++ b/pkg/coredata/migrations/20260709T090905Z.sql @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +-- Extract the SSL/ACME certificate lifecycle out of custom_domains into a +-- generic certificates table keyed on a globally-unique hostname. A custom +-- domain now references its certificate through certificate_id. + +CREATE TABLE certificates ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + hostname CITEXT NOT NULL UNIQUE, + ssl_certificate BYTEA, + encrypted_ssl_private_key BYTEA, + ssl_certificate_chain TEXT, + status custom_domain_ssl_status NOT NULL, + ssl_expires_at TIMESTAMP WITH TIME ZONE, + ssl_retry_count INTEGER NOT NULL DEFAULT 0, + ssl_last_attempt_at TIMESTAMP WITH TIME ZONE, + http_challenge_token TEXT, + http_challenge_key_auth TEXT, + http_challenge_url TEXT, + http_order_url TEXT, + provisioning_error TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_certificates_ssl_expires ON certificates(ssl_expires_at) + WHERE status = 'ACTIVE'; +CREATE INDEX idx_certificates_http_challenge_token ON certificates(http_challenge_token) + WHERE http_challenge_token IS NOT NULL; + +-- Backfill one certificate per existing custom domain. A fresh GID is minted +-- for each row: the source domain's tenant (first 8 bytes of its GID) is kept, +-- the entity type is set to 104 (CertificateEntityType), and a millisecond +-- timestamp plus random suffix guarantee uniqueness. The certificate hostname +-- equals the custom domain name, which lets us link the two afterwards. +INSERT INTO certificates ( + id, + tenant_id, + hostname, + ssl_certificate, + encrypted_ssl_private_key, + ssl_certificate_chain, + status, + ssl_expires_at, + ssl_retry_count, + ssl_last_attempt_at, + http_challenge_token, + http_challenge_key_auth, + http_challenge_url, + http_order_url, + provisioning_error, + created_at, + updated_at +) +SELECT + translate( + encode( + substring(decode(translate(cd.id, '-_', '+/'), 'base64') FROM 1 FOR 8) + || int2send(104::smallint) + || int8send((floor(extract(epoch FROM clock_timestamp()) * 1000))::bigint) + || substring(decode(md5(random()::text || cd.id), 'hex') FROM 1 FOR 6), + 'base64' + ), + '+/', + '-_' + ), + cd.tenant_id, + cd.domain, + cd.ssl_certificate, + cd.encrypted_ssl_private_key, + cd.ssl_certificate_chain, + COALESCE(cd.ssl_status, 'PENDING'), + cd.ssl_expires_at, + COALESCE(cd.ssl_retry_count, 0), + cd.ssl_last_attempt_at, + cd.http_challenge_token, + cd.http_challenge_key_auth, + cd.http_challenge_url, + cd.http_order_url, + cd.provisioning_error, + cd.created_at, + cd.updated_at +FROM custom_domains cd; + +ALTER TABLE custom_domains ADD COLUMN certificate_id TEXT REFERENCES certificates(id) ON DELETE SET NULL; + +UPDATE custom_domains cd +SET certificate_id = c.id +FROM certificates c +WHERE c.hostname = cd.domain; + +-- Repoint the certificate cache from the custom domain to the certificate. +ALTER TABLE cached_certificates ADD COLUMN certificate_id TEXT REFERENCES certificates(id) ON DELETE CASCADE; + +UPDATE cached_certificates cc +SET certificate_id = cd.certificate_id +FROM custom_domains cd +WHERE cd.id = cc.custom_domain_id; + +ALTER TABLE cached_certificates DROP COLUMN custom_domain_id; + +-- Drop the certificate lifecycle columns now living on certificates. +DROP INDEX IF EXISTS idx_custom_domains_ssl_expires; +DROP INDEX IF EXISTS idx_custom_domains_http_challenge_token; + +ALTER TABLE custom_domains + DROP COLUMN ssl_certificate, + DROP COLUMN encrypted_ssl_private_key, + DROP COLUMN ssl_certificate_chain, + DROP COLUMN ssl_status, + DROP COLUMN ssl_expires_at, + DROP COLUMN ssl_retry_count, + DROP COLUMN ssl_last_attempt_at, + DROP COLUMN http_challenge_token, + DROP COLUMN http_challenge_key_auth, + DROP COLUMN http_challenge_url, + DROP COLUMN http_order_url, + DROP COLUMN provisioning_error;