Resolve custom domain SSL via certificate

Replace flattened SSL fields with a certificate
relation loaded through certmanager, and resolve
domain slots from IDs already on the trust center
instead of reloading the compliance page.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-17 17:39:33 +02:00
parent bc128ec516
commit caeb6d9225
13 changed files with 198 additions and 186 deletions

View File

@@ -37,8 +37,10 @@ const fragment = graphql`
id
domain
managed
sslStatus
provisioningError
certificate {
status
provisioningError
}
canDelete: permission(action: "compliance-portal:custom-domain:delete")
...CompliancePageDomainDialogFragment
}
@@ -52,6 +54,8 @@ export function CompliancePageDomainCard(props: {
const { __ } = useTranslate();
const domain = useFragment<CompliancePageDomainCardFragment$key>(fragment, fKey);
const sslStatus = domain.certificate?.status ?? "PENDING";
const provisioningError = domain.certificate?.provisioningError;
return (
<Card padded>
@@ -62,15 +66,15 @@ export function CompliancePageDomainCard(props: {
{domain.managed && (
<Badge variant="neutral">{__("Managed")}</Badge>
)}
<Badge variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}>
{getCustomDomainStatusBadgeLabel(domain.sslStatus, __)}
<Badge variant={getCustomDomainStatusBadgeVariant(sslStatus)}>
{getCustomDomainStatusBadgeLabel(sslStatus, __)}
</Badge>
</div>
<p className="text-sm text-txt-secondary">
{domain.sslStatus === "ACTIVE"
{sslStatus === "ACTIVE"
? __("Verified and serving traffic")
: domain.provisioningError
? domain.provisioningError
: provisioningError
? provisioningError
: __("Pending DNS verification")}
</p>
</div>

View File

@@ -39,9 +39,12 @@ import type { CompliancePageDomainDialogFragment$key } from "#/__generated__/cor
const fragment = graphql`
fragment CompliancePageDomainDialogFragment on CustomDomain {
sslStatus
domain
provisioningError
certificate {
status
expiresAt
provisioningError
}
dnsRecords {
type
name
@@ -49,7 +52,6 @@ const fragment = graphql`
ttl
purpose
}
sslExpiresAt
}
`;
@@ -72,6 +74,9 @@ export function CompliancePageDomainDialog(props: CompliancePageDomainDialogProp
};
const domain = useFragment<CompliancePageDomainDialogFragment$key>(fragment, fKey);
const sslStatus = domain.certificate?.status ?? "PENDING";
const expiresAt = domain.certificate?.expiresAt;
const provisioningError = domain.certificate?.provisioningError;
return (
<Dialog
@@ -80,14 +85,14 @@ export function CompliancePageDomainDialog(props: CompliancePageDomainDialogProp
title={(
<div className="flex items-center gap-3">
<span>{domain.domain}</span>
<Badge variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}>
{getCustomDomainStatusBadgeLabel(domain.sslStatus, __)}
<Badge variant={getCustomDomainStatusBadgeVariant(sslStatus)}>
{getCustomDomainStatusBadgeLabel(sslStatus, __)}
</Badge>
</div>
)}
>
<DialogContent padded className="space-y-6">
{domain.sslStatus === "ACTIVE"
{sslStatus === "ACTIVE"
? (
<div className="bg-subtle rounded-lg p-4">
<div className="flex items-start">
@@ -109,11 +114,11 @@ export function CompliancePageDomainDialog(props: CompliancePageDomainDialogProp
"Your custom domain is verified and SSL certificate is active",
)}
</p>
{domain.sslExpiresAt && (
{expiresAt && (
<p className="text-xs text-txt-tertiary mt-2">
{__("SSL expires")}
{" "}
{new Date(domain.sslExpiresAt).toLocaleDateString()}
{new Date(expiresAt).toLocaleDateString()}
</p>
)}
</div>
@@ -122,10 +127,10 @@ export function CompliancePageDomainDialog(props: CompliancePageDomainDialogProp
)
: (
<div>
{domain.provisioningError && (
{provisioningError && (
<div className="bg-danger-subtle text-danger rounded-lg p-4 mb-4">
<p className="text-sm font-medium mb-1">{__("Provisioning error")}</p>
<p className="text-sm">{domain.provisioningError}</p>
<p className="text-sm">{provisioningError}</p>
</div>
)}
@@ -188,7 +193,7 @@ export function CompliancePageDomainDialog(props: CompliancePageDomainDialogProp
))}
</div>
{domain.sslStatus === "PENDING" && (
{sslStatus === "PENDING" && (
<div className="bg-subtle rounded-lg p-4 mt-4">
<p className="text-sm">
{__(

View File

@@ -42,7 +42,11 @@ const createCustomDomainMutation = graphql`
customDomain {
id
domain
sslStatus
certificate {
status
expiresAt
provisioningError
}
dnsRecords {
type
name
@@ -52,7 +56,6 @@ const createCustomDomainMutation = graphql`
}
createdAt
updatedAt
sslExpiresAt
canDelete: permission(action: "compliance-portal:custom-domain:delete")
...CompliancePageDomainCardFragment
}

View File

@@ -138,73 +138,18 @@ func (s *Service) RemoveCustomDomain(
)
}
// GetDefaultDomain returns the compliance page's default probopage subdomain,
// or nil when it has not been provisioned yet.
func (s *Service) GetDefaultDomain(
// GetDomain returns a custom domain by ID.
func (s *Service) GetDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
domainID gid.GID,
) (*coredata.CustomDomain, error) {
var domain *coredata.CustomDomain
domain := &coredata.CustomDomain{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
compliancePage := &coredata.TrustCenter{}
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.DefaultDomainID == nil {
return nil
}
domain = &coredata.CustomDomain{}
if err := domain.LoadByID(ctx, conn, scope, *compliancePage.DefaultDomainID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
domain = nil
return nil
}
return fmt.Errorf("cannot load custom domain: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return domain, nil
}
func (s *Service) GetCustomDomain(
ctx context.Context,
scope coredata.Scoper,
compliancePageID gid.GID,
) (*coredata.CustomDomain, error) {
var domain *coredata.CustomDomain
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
compliancePage := &coredata.TrustCenter{}
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.CustomDomainID == nil {
return nil
}
domain = &coredata.CustomDomain{}
if err := domain.LoadByID(ctx, conn, scope, *compliancePage.CustomDomainID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
domain = nil
return nil
}
if err := domain.LoadByID(ctx, conn, scope, domainID); err != nil {
return fmt.Errorf("cannot load custom domain: %w", err)
}

View File

@@ -249,31 +249,21 @@ enum TrustCenterFileOrderField
}
enum SSLStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatus") {
@goModel(model: "go.probo.inc/probo/pkg/coredata.CertificateStatus") {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusPending"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.CertificateStatusPending")
PROVISIONING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusProvisioning"
value: "go.probo.inc/probo/pkg/coredata.CertificateStatusProvisioning"
)
ACTIVE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusActive"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.CertificateStatusActive")
RENEWING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusRenewing"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.CertificateStatusRenewing")
EXPIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusExpired"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.CertificateStatusExpired")
FAILED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatusFailed"
)
@goEnum(value: "go.probo.inc/probo/pkg/coredata.CertificateStatusFailed")
}
input TrustCenterAccessOrder
@@ -648,9 +638,7 @@ type CustomDomain implements Node {
organization: Organization!
domain: String!
managed: Boolean!
sslStatus: SSLStatus!
sslExpiresAt: Datetime
provisioningError: String
certificate: Certificate @goField(forceResolver: true)
dnsRecords: [DNSRecordInstruction!]!
createdAt: Datetime!
updatedAt: Datetime!
@@ -658,6 +646,13 @@ type CustomDomain implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type Certificate {
id: ID!
status: SSLStatus!
expiresAt: Datetime
provisioningError: String
}
type DNSRecordInstruction {
type: String!
name: String!

View File

@@ -85,26 +85,6 @@ type (
}
)
// newCustomDomainType loads the domain's certificate (when present) and builds
// the GraphQL CustomDomain type with its certificate-backed SSL fields.
func (r *Resolver) newCustomDomainType(
ctx context.Context,
scope coredata.Scoper,
domain *coredata.CustomDomain,
) (*types.CustomDomain, error) {
var cert *coredata.Certificate
if domain != nil && domain.CertificateID != nil {
var err error
cert, err = r.certManager.Get(ctx, scope, *domain.CertificateID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load certificate", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
}
return types.NewCustomDomain(domain, cert, r.customDomainCname), nil
}
func NewMux(
logger *log.Logger,
proboSvc *probo.Service,

View File

@@ -124,6 +124,30 @@ func (r *compliancePortalCommitmentGroupConnectionResolver) TotalCount(ctx conte
return count, nil
}
// Certificate is the resolver for the certificate field.
func (r *customDomainResolver) Certificate(ctx context.Context, obj *types.CustomDomain) (*types.Certificate, error) {
if obj.Certificate == nil {
return nil, nil
}
scope, err := r.authorize(ctx, obj.ID, management.ActionCustomDomainGet)
if err != nil {
return nil, err
}
cert, err := r.certManager.Get(ctx, scope, obj.Certificate.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load certificate", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewCertificate(cert), nil
}
// Permission is the resolver for the permission field.
func (r *customDomainResolver) Permission(ctx context.Context, obj *types.CustomDomain, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -893,13 +917,8 @@ func (r *mutationResolver) CreateCustomDomain(ctx context.Context, input types.C
return nil, gqlutils.Internal(ctx)
}
customDomain, err := r.newCustomDomainType(ctx, scope, domain)
if err != nil {
return nil, err
}
return &types.CreateCustomDomainPayload{
CustomDomain: customDomain,
CustomDomain: types.NewCustomDomain(domain, r.customDomainCname),
}, nil
}
@@ -1169,42 +1188,50 @@ func (r *trustCenterResolver) MailingList(ctx context.Context, obj *types.TrustC
// DefaultDomain is the resolver for the defaultDomain field.
func (r *trustCenterResolver) DefaultDomain(ctx context.Context, obj *types.TrustCenter) (*types.CustomDomain, error) {
if obj.DefaultDomain == nil {
return nil, nil
}
scope, err := r.authorize(ctx, obj.ID, management.ActionCustomDomainGet)
if err != nil {
return nil, err
}
domain, err := r.management.GetDefaultDomain(ctx, scope, obj.ID)
domain, err := r.management.GetDomain(ctx, scope, obj.DefaultDomain.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load default domain", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if domain == nil {
return nil, nil
}
return r.newCustomDomainType(ctx, scope, domain)
return types.NewCustomDomain(domain, r.customDomainCname), nil
}
// CustomDomain is the resolver for the customDomain field.
func (r *trustCenterResolver) CustomDomain(ctx context.Context, obj *types.TrustCenter) (*types.CustomDomain, error) {
if obj.CustomDomain == nil {
return nil, nil
}
scope, err := r.authorize(ctx, obj.ID, management.ActionCustomDomainGet)
if err != nil {
return nil, err
}
domain, err := r.management.GetCustomDomain(ctx, scope, obj.ID)
domain, err := r.management.GetDomain(ctx, scope, obj.CustomDomain.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load custom domain", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if domain == nil {
return nil, nil
}
return r.newCustomDomainType(ctx, scope, domain)
return types.NewCustomDomain(domain, r.customDomainCname), nil
}
// PublicURL is the resolver for the publicUrl field.

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewCertificate(c *coredata.Certificate) *Certificate {
return &Certificate{
ID: c.ID,
Status: c.Status,
ExpiresAt: c.SSLExpiresAt,
ProvisioningError: c.ProvisioningError,
}
}

View File

@@ -24,46 +24,34 @@ import (
"go.probo.inc/probo/pkg/coredata"
)
// NewCustomDomain builds the GraphQL CustomDomain type. The TLS lifecycle now
// lives on the linked certificate; when cert is nil (certificate not yet
// created) the domain reports a pending SSL status.
func NewCustomDomain(d *coredata.CustomDomain, cert *coredata.Certificate, cnameTarget string) *CustomDomain {
result := &CustomDomain{
func NewCustomDomain(d *coredata.CustomDomain, cnameTarget string) *CustomDomain {
domain := &CustomDomain{
ID: d.ID,
Organization: &Organization{
ID: d.OrganizationID,
},
Domain: d.Domain,
Managed: d.Managed,
SslStatus: coredata.CustomDomainSSLStatusPending,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Domain: d.Domain,
Managed: d.Managed,
DNSRecords: convertDNSRecords(d, cnameTarget),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
if cert != nil {
result.SslStatus = coredata.CustomDomainSSLStatus(cert.Status)
result.SslExpiresAt = cert.SSLExpiresAt
result.ProvisioningError = cert.ProvisioningError
if d.CertificateID != nil {
domain.Certificate = &Certificate{ID: *d.CertificateID}
}
// Convert DNS records
result.DNSRecords = convertDNSRecords(d, cnameTarget)
return result
return domain
}
func convertDNSRecords(d *coredata.CustomDomain, cnameTarget string) []*DNSRecordInstruction {
var records []*DNSRecordInstruction
// For HTTP-01 challenges, we just need the domain to point to our servers via CNAME
record := &DNSRecordInstruction{
Type: "CNAME",
Name: d.Domain,
Value: cnameTarget,
TTL: 300,
Purpose: "Point domain to Probo servers",
return []*DNSRecordInstruction{
{
Type: "CNAME",
Name: d.Domain,
Value: cnameTarget,
TTL: 300,
Purpose: "Point domain to Probo servers",
},
}
records = append(records, record)
return records
}

View File

@@ -47,6 +47,8 @@ type TrustCenter struct {
ComplianceFrameworks *ComplianceFrameworkConnection `json:"complianceFrameworks"`
CustomLinks *ComplianceCustomLinkConnection `json:"customLinks"`
MailingList *MailingList `json:"mailingList,omitempty"`
DefaultDomain *CustomDomain `json:"defaultDomain,omitempty"`
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
Permission bool `json:"permission"`
}
@@ -82,5 +84,13 @@ func NewTrustCenter(tc *coredata.TrustCenter) *TrustCenter {
trustCenter.Nda = &File{ID: *tc.NonDisclosureAgreementFileID}
}
if tc.DefaultDomainID != nil {
trustCenter.DefaultDomain = &CustomDomain{ID: *tc.DefaultDomainID}
}
if tc.CustomDomainID != nil {
trustCenter.CustomDomain = &CustomDomain{ID: *tc.CustomDomainID}
}
return trustCenter
}

View File

@@ -5306,15 +5306,20 @@ func (r *Resolver) DeleteCustomDomainTool(ctx context.Context, req *mcp.CallTool
return nil, types.DeleteCustomDomainOutput{}, err
}
domain, err := r.management.GetCustomDomain(ctx, scope, input.TrustCenterID)
compliancePage, err := r.management.Get(ctx, scope, input.TrustCenterID)
if err != nil {
return nil, types.DeleteCustomDomainOutput{}, fmt.Errorf("cannot get custom domain: %w", err)
return nil, types.DeleteCustomDomainOutput{}, fmt.Errorf("cannot load compliance page: %w", err)
}
if domain == nil {
if compliancePage.CustomDomainID == nil {
return nil, types.DeleteCustomDomainOutput{}, fmt.Errorf("compliance page has no custom domain")
}
domain, err := r.management.GetDomain(ctx, scope, *compliancePage.CustomDomainID)
if err != nil {
return nil, types.DeleteCustomDomainOutput{}, fmt.Errorf("cannot get custom domain: %w", err)
}
var cert *coredata.Certificate
if domain.CertificateID != nil {
cert, err = r.certManager.Get(ctx, scope, *domain.CertificateID)

View File

@@ -8737,7 +8737,7 @@ components:
- RENEWING
- EXPIRED
- FAILED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.CustomDomainSSLStatus
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.CertificateStatus
TrustCenterReferenceOrderField:
type: string
@@ -8968,6 +8968,26 @@ components:
type: string
format: date-time
Certificate:
type: object
required:
- id
- status
properties:
id:
$ref: "#/components/schemas/GID"
status:
$ref: "#/components/schemas/SSLStatus"
expires_at:
anyOf:
- type: string
format: date-time
- type: "null"
provisioning_error:
anyOf:
- type: string
- type: "null"
CustomDomain:
type: object
required:
@@ -8975,7 +8995,6 @@ components:
- organization_id
- domain
- managed
- ssl_status
- created_at
- updated_at
properties:
@@ -8988,12 +9007,9 @@ components:
managed:
type: boolean
description: Whether this domain is a Probo-managed probopage subdomain
ssl_status:
$ref: "#/components/schemas/SSLStatus"
ssl_expires_at:
certificate:
anyOf:
- type: string
format: date-time
- $ref: "#/components/schemas/Certificate"
- type: "null"
created_at:
type: string

View File

@@ -24,24 +24,24 @@ import (
"go.probo.inc/probo/pkg/coredata"
)
// NewCustomDomain builds the MCP CustomDomain type. The TLS lifecycle now lives
// on the linked certificate; when cert is nil (certificate not yet created) the
// domain reports a pending SSL status.
func NewCustomDomain(d *coredata.CustomDomain, cert *coredata.Certificate) *CustomDomain {
result := &CustomDomain{
domain := &CustomDomain{
ID: d.ID,
OrganizationID: d.OrganizationID,
Domain: d.Domain,
Managed: d.Managed,
SslStatus: coredata.CustomDomainSSLStatusPending,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
if cert != nil {
result.SslStatus = coredata.CustomDomainSSLStatus(cert.Status)
result.SslExpiresAt = cert.SSLExpiresAt
domain.Certificate = &Certificate{
ID: cert.ID,
Status: cert.Status,
ExpiresAt: cert.SSLExpiresAt,
ProvisioningError: cert.ProvisioningError,
}
}
return result
return domain
}