Extract certificate lifecycle into certificates

Pull SSL/ACME state out of custom_domains into a dedicated hostname-keyed
certificates table, and repoint cached certificates from the domain to
the certificate. Custom domains now reference a certificate id, keeping
certificate provisioning decoupled from domain business logic.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-10 15:10:04 +02:00
parent 75203acff2
commit 7c5759733c
6 changed files with 1218 additions and 577 deletions

View File

@@ -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)

915
pkg/coredata/certificate.go Normal file
View File

@@ -0,0 +1,915 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,131 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- 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;