Harden compliance portal auth and TLS

Align console references and OAuth branding with the
compliance-page model, and fix certificate cache eviction,
portal OAuth handlers, and magic-link edge cases left after
the trust-center rename.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-20 09:59:25 +02:00
parent b03acbd029
commit 43ce3a7c53
51 changed files with 626 additions and 458 deletions

View File

@@ -35,11 +35,11 @@ import { forwardRef, type ReactNode, useImperativeHandle, useState } from "react
import { z } from "zod";
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
import {
useCreateTrustCenterReferenceMutation,
useUpdateTrustCenterReferenceMutation,
} from "#/hooks/graph/TrustCenterReferenceGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import {
useCreateCompliancePageReferenceMutation,
useUpdateCompliancePageReferenceMutation,
} from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
const referenceSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -65,8 +65,8 @@ export const CompliancePageReferenceDialog = forwardRef<CompliancePageReferenceD
const [editReference, setEditReference] = useState<CompliancePageReferenceListItemFragment$data | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [createReference, isCreating] = useCreateTrustCenterReferenceMutation();
const [updateReference, isUpdating] = useUpdateTrustCenterReferenceMutation();
const [createReference, isCreating] = useCreateCompliancePageReferenceMutation();
const [updateReference, isUpdating] = useUpdateCompliancePageReferenceMutation();
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(
referenceSchema,

View File

@@ -30,9 +30,9 @@ import {
useDialogRef,
} from "@probo/ui";
import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
import { deleteTrustCenterReferenceMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
import { deleteCompliancePageReferenceMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
type Props = {
children: React.ReactNode;
@@ -52,8 +52,8 @@ export function DeleteCompliancePageReferenceDialog({
const { __ } = useTranslate();
const ref = useDialogRef();
const [mutate, isDeleting] = useMutation<TrustCenterReferenceGraphDeleteMutation>(
deleteTrustCenterReferenceMutation,
const [mutate, isDeleting] = useMutation<compliancePageReferenceMutationsDeleteMutation>(
deleteCompliancePageReferenceMutation,
{
successMessage: __("Reference deleted successfully"),
errorToast: __("Failed to delete reference"),

View File

@@ -1,135 +0,0 @@
// 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.
import { graphql } from "react-relay";
import type { TrustCenterReferenceGraphCreateMutation } from "#/__generated__/core/TrustCenterReferenceGraphCreateMutation.graphql";
import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
import type { TrustCenterReferenceGraphUpdateMutation } from "#/__generated__/core/TrustCenterReferenceGraphUpdateMutation.graphql";
import type { TrustCenterReferenceGraphUpdateRankMutation } from "#/__generated__/core/TrustCenterReferenceGraphUpdateRankMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
export const createTrustCenterReferenceMutation = graphql`
mutation TrustCenterReferenceGraphCreateMutation(
$input: CreateTrustCenterReferenceInput!
$connections: [ID!]!
) {
createTrustCenterReference(input: $input) {
trustCenterReferenceEdge @appendEdge(connections: $connections) {
cursor
node {
id
name
description
websiteUrl
logo {
downloadUrl
}
rank
createdAt
updatedAt
canUpdate: permission(action: "compliance-portal:portal-reference:update")
canDelete: permission(action: "compliance-portal:portal-reference:delete")
}
}
}
}
`;
export const updateTrustCenterReferenceMutation = graphql`
mutation TrustCenterReferenceGraphUpdateMutation(
$input: UpdateTrustCenterReferenceInput!
) {
updateTrustCenterReference(input: $input) {
trustCenterReference {
id
name
description
websiteUrl
logo {
downloadUrl
}
rank
createdAt
updatedAt
canUpdate: permission(action: "compliance-portal:portal-reference:update")
canDelete: permission(action: "compliance-portal:portal-reference:delete")
}
}
}
`;
export const deleteTrustCenterReferenceMutation = graphql`
mutation TrustCenterReferenceGraphDeleteMutation(
$input: DeleteTrustCenterReferenceInput!
$connections: [ID!]!
) {
deleteTrustCenterReference(input: $input) {
deletedTrustCenterReferenceId @deleteEdge(connections: $connections)
}
}
`;
export function useCreateTrustCenterReferenceMutation() {
return useMutation<TrustCenterReferenceGraphCreateMutation>(
createTrustCenterReferenceMutation,
{
successMessage: "Reference created successfully",
errorToast: "Failed to create reference",
},
);
}
export function useUpdateTrustCenterReferenceMutation() {
return useMutation<TrustCenterReferenceGraphUpdateMutation>(
updateTrustCenterReferenceMutation,
{
successMessage: "Reference updated successfully",
errorToast: "Failed to update reference",
},
);
}
export const updateTrustCenterReferenceRankMutation = graphql`
mutation TrustCenterReferenceGraphUpdateRankMutation(
$input: UpdateTrustCenterReferenceInput!
) {
updateTrustCenterReference(input: $input) {
trustCenterReference {
id
rank
}
}
}
`;
export function useUpdateTrustCenterReferenceRankMutation() {
return useMutation<TrustCenterReferenceGraphUpdateRankMutation>(
updateTrustCenterReferenceRankMutation,
{
successMessage: "Order updated successfully",
errorToast: "Failed to update order",
},
);
}
export function useDeleteTrustCenterReferenceMutation() {
return useMutation<TrustCenterReferenceGraphDeleteMutation>(
deleteTrustCenterReferenceMutation,
{
successMessage: "Reference deleted successfully",
errorToast: "Failed to delete reference",
},
);
}

View File

@@ -1,25 +1,29 @@
// 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.
// 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 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.
// 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.
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui";
import { useNavigate } from "react-router";
export default function MagicLinkAlreadyUsedPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
usePageTitle(__("Link Already Used"));
@@ -33,10 +37,7 @@ export default function MagicLinkAlreadyUsedPage() {
)}
</p>
</div>
<Button
className="w-full h-10"
onClick={() => void navigate("/auth/login")}
>
<Button className="w-full h-10" to="/auth/login">
{__("Sign in")}
</Button>
</div>

View File

@@ -1,25 +1,29 @@
// 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.
// 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 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.
// 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.
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui";
import { useNavigate } from "react-router";
export default function MagicLinkExpiredPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
usePageTitle(__("Link Expired"));
@@ -33,10 +37,7 @@ export default function MagicLinkExpiredPage() {
)}
</p>
</div>
<Button
className="w-full h-10"
onClick={() => void navigate("/auth/login")}
>
<Button className="w-full h-10" to="/auth/login">
{__("Sign in")}
</Button>
</div>

View File

@@ -42,9 +42,7 @@ export const signInPageQuery = graphql`
oauthClientBranding(clientId: $clientId) {
name
clientURL
logo {
downloadUrl
}
logoUrl
}
}
`;
@@ -85,7 +83,7 @@ export default function SignInPage(props: Props) {
<>
<OAuthClientBrandingSection
name={clientBranding.name}
logoDownloadUrl={clientBranding.logo?.downloadUrl}
logoDownloadUrl={clientBranding.logoUrl}
clientURL={clientBranding.clientURL}
/>
<div className="w-full border-t border-t-border-mid" />

View File

@@ -1,16 +1,22 @@
// 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.
// 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 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.
// 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.
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
@@ -67,12 +73,22 @@ export function MagicLinkForm() {
body.set("email", email);
body.set("continue", postAuthRedirectUrl);
const response = await fetch("/api/connect/v1/magic-link/send", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
credentials: "include",
body,
});
let response: Response;
try {
response = await fetch("/api/connect/v1/magic-link/send", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
credentials: "include",
body,
});
} catch {
toast({
title: __("Error"),
description: __("Cannot send magic link"),
variant: "error",
});
return;
}
if (!response.ok) {
toast({

View File

@@ -0,0 +1,141 @@
// 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.
import { graphql } from "react-relay";
import type { compliancePageReferenceMutationsCreateMutation } from "#/__generated__/core/compliancePageReferenceMutationsCreateMutation.graphql";
import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
import type { compliancePageReferenceMutationsUpdateMutation } from "#/__generated__/core/compliancePageReferenceMutationsUpdateMutation.graphql";
import type { compliancePageReferenceMutationsUpdateRankMutation } from "#/__generated__/core/compliancePageReferenceMutationsUpdateRankMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
export const createCompliancePageReferenceMutation = graphql`
mutation compliancePageReferenceMutationsCreateMutation(
$input: CreateTrustCenterReferenceInput!
$connections: [ID!]!
) {
createTrustCenterReference(input: $input) {
trustCenterReferenceEdge @appendEdge(connections: $connections) {
cursor
node {
id
name
description
websiteUrl
logo {
downloadUrl
}
rank
createdAt
updatedAt
canUpdate: permission(action: "compliance-portal:portal-reference:update")
canDelete: permission(action: "compliance-portal:portal-reference:delete")
}
}
}
}
`;
export const updateCompliancePageReferenceMutation = graphql`
mutation compliancePageReferenceMutationsUpdateMutation(
$input: UpdateTrustCenterReferenceInput!
) {
updateTrustCenterReference(input: $input) {
trustCenterReference {
id
name
description
websiteUrl
logo {
downloadUrl
}
rank
createdAt
updatedAt
canUpdate: permission(action: "compliance-portal:portal-reference:update")
canDelete: permission(action: "compliance-portal:portal-reference:delete")
}
}
}
`;
export const deleteCompliancePageReferenceMutation = graphql`
mutation compliancePageReferenceMutationsDeleteMutation(
$input: DeleteTrustCenterReferenceInput!
$connections: [ID!]!
) {
deleteTrustCenterReference(input: $input) {
deletedTrustCenterReferenceId @deleteEdge(connections: $connections)
}
}
`;
export function useCreateCompliancePageReferenceMutation() {
return useMutation<compliancePageReferenceMutationsCreateMutation>(
createCompliancePageReferenceMutation,
{
successMessage: "Reference created successfully",
errorToast: "Failed to create reference",
},
);
}
export function useUpdateCompliancePageReferenceMutation() {
return useMutation<compliancePageReferenceMutationsUpdateMutation>(
updateCompliancePageReferenceMutation,
{
successMessage: "Reference updated successfully",
errorToast: "Failed to update reference",
},
);
}
export const updateCompliancePageReferenceRankMutation = graphql`
mutation compliancePageReferenceMutationsUpdateRankMutation(
$input: UpdateTrustCenterReferenceInput!
) {
updateTrustCenterReference(input: $input) {
trustCenterReference {
id
rank
}
}
}
`;
export function useUpdateCompliancePageReferenceRankMutation() {
return useMutation<compliancePageReferenceMutationsUpdateRankMutation>(
updateCompliancePageReferenceRankMutation,
{
successMessage: "Order updated successfully",
errorToast: "Failed to update order",
},
);
}
export function useDeleteCompliancePageReferenceMutation() {
return useMutation<compliancePageReferenceMutationsDeleteMutation>(
deleteCompliancePageReferenceMutation,
{
successMessage: "Reference deleted successfully",
errorToast: "Failed to delete reference",
},
);
}

View File

@@ -27,7 +27,7 @@ import { graphql } from "relay-runtime";
import type { CompliancePageReferenceListFragment$key } from "#/__generated__/core/CompliancePageReferenceListFragment.graphql";
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.graphql";
import { useUpdateTrustCenterReferenceRankMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
import { useUpdateCompliancePageReferenceRankMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
@@ -65,7 +65,7 @@ export function CompliancePageReferenceList(props: {
CompliancePageReferenceListQuery,
CompliancePageReferenceListFragment$key
>(fragment, fragmentRef);
const [updateRank] = useUpdateTrustCenterReferenceRankMutation();
const [updateRank] = useUpdateCompliancePageReferenceRankMutation();
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);

View File

@@ -94,7 +94,6 @@ export const currentTrustDocumentsQuery = graphql`
query TrustGraphCurrentDocumentsQuery {
currentTrustCenter {
id
title
documents(first: 50) {
edges {
node {
@@ -121,7 +120,6 @@ export const currentTrustSubprocessorsQuery = graphql`
query TrustGraphCurrentSubprocessorsQuery {
currentTrustCenter {
id
title
subprocessors(first: 50) {
edges {
node {

View File

@@ -39,8 +39,9 @@ import (
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
// HTTPS listener started by the e2e probod (see generateConfig). Compliance
// pages are served here exclusively, routed by TLS SNI / Host header.
const trustCenterHTTPSAddr = "127.0.0.1:443"
// pages are served here exclusively, routed by TLS SNI / Host header. Uses a
// non-privileged port so the e2e suite doesn't require root/CAP_NET_BIND_SERVICE.
const trustCenterHTTPSAddr = "127.0.0.1:8443"
type GraphQLRequest struct {
Query string `json:"query"`

View File

@@ -298,7 +298,7 @@ func generateConfig() (string, error) {
// yields {slug}.probopage.localhost subdomains for pages without a
// customer custom domain.
"PROBOD_TRUST_CENTER_HTTP_ADDR": ":10080",
"PROBOD_TRUST_CENTER_HTTPS_ADDR": ":443",
"PROBOD_TRUST_CENTER_HTTPS_ADDR": ":8443",
"PROBOD_TRUST_CENTER_BASE_DOMAIN": "probopage.localhost",
// Keep certificate provisioning snappy so trust-center e2e flows do not

View File

@@ -59,8 +59,14 @@ func (w *CacheStore) WarmCache(ctx context.Context) error {
err := w.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var domains coredata.CustomDomains
keepCertificateIDs, err := domains.LoadReferencedCertificateIDs(ctx, conn)
if err != nil {
return fmt.Errorf("cannot load referenced certificate ids: %w", err)
}
var caches coredata.CachedCertificates
if err := caches.DeleteUnreferenced(ctx, conn); err != nil {
if err := caches.DeleteWhereCertificateIDNotIn(ctx, conn, keepCertificateIDs); err != nil {
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
}
@@ -116,19 +122,6 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
return fmt.Errorf("cannot parse certificate: %w", err)
}
if len(loadedCertificate.SSLCertificatePEM) == 0 {
return fmt.Errorf("certificate has no certificate PEM")
}
privateKeyPEM, err := loadedCertificate.DecryptPrivateKey(w.encryptionKey)
if err != nil {
return fmt.Errorf("cannot decrypt private key: %w", err)
}
if len(privateKeyPEM) == 0 {
return fmt.Errorf("certificate has no private key PEM")
}
if loadedCertificate.SSLExpiresAt == nil {
return fmt.Errorf("certificate has no expiry date")
}
@@ -137,18 +130,9 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
return fmt.Errorf("certificate has expired")
}
cache := &coredata.CachedCertificate{
Domain: loadedCertificate.Hostname,
CertificatePEM: string(loadedCertificate.SSLCertificatePEM),
PrivateKeyPEM: string(privateKeyPEM),
CertificateChain: loadedCertificate.SSLCertificateChain,
ExpiresAt: *loadedCertificate.SSLExpiresAt,
CachedAt: time.Now(),
CertificateID: loadedCertificate.ID,
}
if err := cache.Upsert(ctx, conn); err != nil {
return fmt.Errorf("cannot upsert cache entry: %w", err)
var cache coredata.CachedCertificate
if err := cache.RefreshFromCertificate(ctx, conn, &loadedCertificate, w.encryptionKey); err != nil {
return fmt.Errorf("cannot refresh certificate cache: %w", err)
}
return nil

View File

@@ -97,6 +97,12 @@ func (h *renewHandler) Process(ctx context.Context, certificate coredata.Certifi
func(ctx context.Context, tx pg.Tx) error {
fullCertificate := &coredata.Certificate{}
if err := fullCertificate.LoadByIDForUpdateSkipLocked(ctx, tx, coredata.NewNoScope(), certificate.ID); err != nil {
// Another provision/renewal cycle may already hold the row
// (SKIP LOCKED) or the certificate may have been deleted.
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load certificate for renewal: %w", err)
}

View File

@@ -26,7 +26,6 @@ import (
"errors"
"fmt"
"sync"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
@@ -68,7 +67,14 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
if cached, ok := s.cache.Load(domain); ok {
if cert, ok := cached.(*tls.Certificate); ok {
return cert, nil
if err := s.checkRoutable(domain); err == nil {
return cert, nil
}
// The domain was deleted or is no longer routable since the
// cache entry was stored; evict it and fall through to a fresh
// database load below.
s.cache.Delete(domain)
}
}
@@ -82,6 +88,21 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return cert, nil
}
// checkRoutable reports whether domain is still a routable custom domain
// with an active certificate. It is used to revalidate memory-cache hits so
// certificates for deleted or de-provisioned domains stop being served
// without waiting for process restart.
func (s *Selector) checkRoutable(domain string) error {
ctx := context.Background()
return s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return requireRoutableDomain(ctx, conn, domain)
},
)
}
func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) {
ctx := context.Background()
@@ -153,32 +174,20 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Querier, domai
return fmt.Errorf("certificate has no encrypted private key data")
}
privateKeyPEM, err := certificate.DecryptPrivateKey(s.encryptionKey)
if err != nil {
return fmt.Errorf("cannot decrypt private key: %w", err)
if certificate.SSLExpiresAt == nil {
return fmt.Errorf("certificate has no expiry")
}
s.cache.Store(domain, certificate.SSLCertificate)
cache := &coredata.CachedCertificate{
Domain: certificate.Hostname,
CertificatePEM: string(certificate.SSLCertificatePEM),
PrivateKeyPEM: string(privateKeyPEM),
CertificateChain: certificate.SSLCertificateChain,
ExpiresAt: *certificate.SSLExpiresAt,
CachedAt: time.Now(),
CertificateID: certificate.ID,
}
if err := cache.Upsert(ctx, conn); err != nil {
var cache coredata.CachedCertificate
if err := cache.RefreshFromCertificate(ctx, conn, &certificate, s.encryptionKey); err != nil {
return fmt.Errorf("cannot insert cache entry: %w", err)
}
return nil
}
// requireRoutableDomain ensures the SNI hostname still maps to a custom domain
// row. Orphaned certificates left after domain deletion must not be served.
func requireRoutableDomain(ctx context.Context, conn pg.Querier, domain string) error {
var customDomain coredata.CustomDomain
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {

View File

@@ -182,7 +182,11 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
}
if cmd.Flags().Changed("email") {
input["email"] = flagEmail
if flagEmail == "" {
input["email"] = nil
} else {
input["email"] = flagEmail
}
}
if cmd.Flags().Changed("headquarter-address") {

View File

@@ -65,7 +65,7 @@ func (s *Service) PublicURLForCompliancePage(
switch {
case compliancePage.CustomDomainID != nil && byID[*compliancePage.CustomDomainID] != nil && active[*compliancePage.CustomDomainID]:
host = byID[*compliancePage.CustomDomainID].Domain
case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil:
case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil && active[*compliancePage.DefaultDomainID]:
host = byID[*compliancePage.DefaultDomainID].Domain
}

View File

@@ -1,16 +1,22 @@
// 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.
// 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 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.
// 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 management
@@ -18,16 +24,11 @@ import (
"go.probo.inc/probo/pkg/coredata"
)
// The compliance-page scope string values are part of the external OAuth2
// contract and are kept stable even though the feature is named "compliance
// portal" on the Go side.
const (
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
)
// OAuth2ScopeMappings maps the compliance portal OAuth2 scopes to the actions
// they grant.
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ScopeV1CompliancePortalRead: {
ActionCompliancePortalGet,
@@ -45,6 +46,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList,
ActionComplianceCustomLinkList,
ActionCustomDomainGet,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
},
ScopeV1CompliancePortal: {
ActionCompliancePortalGet,
@@ -62,6 +65,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList,
ActionComplianceCustomLinkList,
ActionCustomDomainGet,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
ActionCompliancePortalUpdate,
ActionCompliancePortalNonDisclosureAgreementUpload,
ActionCompliancePortalNonDisclosureAgreementDelete,
@@ -89,5 +94,13 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceCustomLinkDelete,
ActionCustomDomainCreate,
ActionCustomDomainDelete,
ActionCompliancePortalCommitmentGroupCreate,
ActionCompliancePortalCommitmentGroupUpdate,
ActionCompliancePortalCommitmentGroupUpdateRank,
ActionCompliancePortalCommitmentGroupDelete,
ActionCompliancePortalCommitmentCreate,
ActionCompliancePortalCommitmentUpdate,
ActionCompliancePortalCommitmentUpdateRank,
ActionCompliancePortalCommitmentDelete,
},
}

View File

@@ -44,6 +44,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionCompliancePortalReferenceList, ActionCompliancePortalReferenceGetLogoUrl,
ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList,
ActionComplianceFrameworkList,
ActionComplianceCustomLinkList,
).WithSID("compliance-portal-read-access").When(organizationCondition),
).WithDescription("Read-only compliance portal access for organization viewers")

View File

@@ -14,33 +14,15 @@
package visitor
import (
"fmt"
"net/url"
)
const (
BrandLogoPath = "/brand/logo"
BrandDarkLogoPath = "/brand/dark-logo"
)
func BrandLogoURL(portalBaseURL string) (string, error) {
return brandAssetURL(portalBaseURL, BrandLogoPath)
return portalEndpointURL(portalBaseURL, BrandLogoPath)
}
func BrandDarkLogoURL(portalBaseURL string) (string, error) {
return brandAssetURL(portalBaseURL, BrandDarkLogoPath)
}
func brandAssetURL(portalBaseURL string, path string) (string, error) {
parsed, err := url.Parse(portalBaseURL)
if err != nil {
return "", fmt.Errorf("cannot parse portal base URL: %w", err)
}
parsed.Path = path
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
return portalEndpointURL(portalBaseURL, BrandDarkLogoPath)
}

View File

@@ -29,38 +29,26 @@ const (
)
func CIMDClientIDURL(portalBaseURL string) (string, error) {
parsed, err := url.Parse(portalBaseURL)
if err != nil {
return "", err
}
parsed.Path = CIMDMetadataPath
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
return portalEndpointURL(portalBaseURL, CIMDMetadataPath)
}
func OAuthCallbackURL(portalBaseURL string) (string, error) {
parsed, err := url.Parse(portalBaseURL)
if err != nil {
return "", err
}
parsed.Path = OAuthCallbackPath
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
return portalEndpointURL(portalBaseURL, OAuthCallbackPath)
}
func PortalRootURL(rawURL string) (string, error) {
parsed, err := url.Parse(rawURL)
return portalEndpointURL(rawURL, "")
}
// portalEndpointURL replaces the path on a portal base URL and clears
// query/fragment. Shared by CIMD, OAuth callback, and brand asset URLs.
func portalEndpointURL(portalBaseURL string, path string) (string, error) {
parsed, err := url.Parse(portalBaseURL)
if err != nil {
return "", fmt.Errorf("cannot parse portal URL: %w", err)
}
parsed.Path = ""
parsed.Path = path
parsed.RawQuery = ""
parsed.Fragment = ""

View File

@@ -55,28 +55,3 @@ func (s *Service) ListCommitmentGroupsForPortalID(
return page.NewPage(groups, cursor), nil
}
func (s *Service) GetCommitmentGroup(
ctx context.Context,
scope coredata.Scoper,
groupID gid.GID,
) (*coredata.CompliancePortalCommitmentGroup, error) {
group := &coredata.CompliancePortalCommitmentGroup{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
err := group.LoadByID(ctx, conn, scope, groupID)
if err != nil {
return fmt.Errorf("cannot load compliance portal commitment group: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return group, nil
}

View File

@@ -24,6 +24,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -153,6 +154,38 @@ func (s *Service) GetPortalEffectiveCanonicalHost(ctx context.Context, complianc
return host, nil
}
// GetPortalCanonicalBaseURL rewrites currentBaseURL to the compliance page's
// canonical host, if one is set. OAuth client_id and redirect_uri values must
// always be derived from the canonical base URL: the SNI middleware only
// redirects secondary domains to the canonical host for non-well-known
// paths, so a client_id fetched from /.well-known/oauth-client-metadata on a
// secondary domain must already advertise the canonical redirect_uri to stay
// consistent with what /callback uses at token exchange time. When no
// canonical host can be determined, currentBaseURL is returned unchanged.
func (s *Service) GetPortalCanonicalBaseURL(
ctx context.Context,
compliancePageID gid.GID,
currentBaseURL string,
) (string, error) {
canonicalHost, err := s.GetPortalEffectiveCanonicalHost(ctx, compliancePageID)
if err != nil {
return "", fmt.Errorf("cannot resolve canonical host: %w", err)
}
if canonicalHost == "" {
return currentBaseURL, nil
}
parsed, err := url.Parse(currentBaseURL)
if err != nil {
return "", fmt.Errorf("cannot parse portal base URL: %w", err)
}
parsed.Host = canonicalHost
return parsed.String(), nil
}
func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
compliancePage := &coredata.TrustCenter{}

View File

@@ -170,22 +170,25 @@ WHERE
return nil
}
// DeleteUnreferenced removes cache entries whose certificate is no longer
// referenced by any custom domain, so deleted domains cannot keep a usable
// TLS cache entry.
func (cc *CachedCertificates) DeleteUnreferenced(ctx context.Context, conn pg.Querier) error {
// DeleteWhereCertificateIDNotIn removes cache rows whose certificate is not
// among the provided IDs. An empty keep set deletes every cache row.
func (cc *CachedCertificates) DeleteWhereCertificateIDNotIn(
ctx context.Context,
conn pg.Querier,
keepCertificateIDs []gid.GID,
) error {
q := `
DELETE FROM
cached_certificates
WHERE
NOT EXISTS (
SELECT 1
FROM custom_domains
WHERE custom_domains.certificate_id = cached_certificates.certificate_id
)
NOT (certificate_id = ANY(@keep_certificate_ids::text[]))
`
_, err := conn.Exec(ctx, q, pgx.NamedArgs{})
_, err := conn.Exec(
ctx,
q,
pgx.NamedArgs{"keep_certificate_ids": keepCertificateIDs},
)
if err != nil {
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
}

View File

@@ -377,3 +377,32 @@ WHERE
return nil
}
// LoadReferencedCertificateIDs returns certificate IDs currently linked from
// any custom domain. Used by the certificate cache warmer to drop orphaned
// cache rows without joining across entity tables.
func (domains *CustomDomains) LoadReferencedCertificateIDs(
ctx context.Context,
conn pg.Querier,
) ([]gid.GID, error) {
q := `
SELECT DISTINCT
certificate_id
FROM
custom_domains
WHERE
certificate_id IS NOT NULL
`
rows, err := conn.Query(ctx, q, pgx.NamedArgs{})
if err != nil {
return nil, fmt.Errorf("cannot query referenced certificate ids: %w", err)
}
certificateIDs, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
if err != nil {
return nil, fmt.Errorf("cannot collect referenced certificate ids: %w", err)
}
return certificateIDs, nil
}

View File

@@ -24,6 +24,7 @@ INSERT INTO trust_centers (
tenant_id,
active,
slug,
search_engine_indexing,
created_at,
updated_at
)
@@ -41,6 +42,7 @@ SELECT
'\s+', '-', 'g'
)
),
'NOT_INDEXABLE',
NOW(),
NOW()
FROM organizations o

View File

@@ -25,7 +25,7 @@ CREATE TABLE certificates (
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_retry_count INTEGER NOT NULL,
ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
http_challenge_token TEXT,
http_challenge_key_auth TEXT,
@@ -100,6 +100,12 @@ SET certificate_id = cd.certificate_id
FROM custom_domains cd
WHERE cd.id = cc.custom_domain_id;
-- Entries that could not be repointed (stale custom_domain_id, or a domain
-- whose certificate was never migrated) are unusable cache rows; drop them
-- rather than leaving certificate_id NULL for callers that always expect it.
DELETE FROM cached_certificates WHERE certificate_id IS NULL;
ALTER TABLE cached_certificates ALTER COLUMN certificate_id SET NOT NULL;
ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
-- Drop the certificate lifecycle columns now living on certificates.

View File

@@ -29,6 +29,19 @@ WITH pending_pages AS (
FROM trust_centers tc
WHERE tc.default_domain_id IS NULL
AND NULLIF(current_setting('probo.trust_center_base_domain', true), '') IS NOT NULL
-- Skip hostnames that already exist: minting a certificate or custom
-- domain for them would violate their unique constraints and abort
-- the whole migration.
AND NOT EXISTS (
SELECT 1
FROM certificates c
WHERE c.hostname = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
)
AND NOT EXISTS (
SELECT 1
FROM custom_domains cd
WHERE cd.domain = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
)
),
minted_certificates AS (
INSERT INTO certificates (

View File

@@ -17,5 +17,5 @@
UPDATE trust_centers
SET
slug = slug || '-' || encode(gen_random_bytes(4), 'hex'),
slug = slug || '-' || encode(gen_random_bytes(16), 'hex'),
updated_at = clock_timestamp();

View File

@@ -32,6 +32,11 @@ import (
"strings"
)
// minRSAModulusBits is the smallest RSA modulus size accepted for RS256
// verification. NIST SP 800-131A and industry guidance both treat moduli
// below 2048 bits as too weak for continued use.
const minRSAModulusBits = 2048
type (
// JWK represents a JSON Web Key (RFC 7517).
JWK struct {
@@ -126,8 +131,13 @@ func RSAPublicKeyFromJWK(jwk JWK) (*rsa.PublicKey, error) {
return nil, fmt.Errorf("cannot convert jwk to rsa public key: invalid rsa exponent")
}
n := new(big.Int).SetBytes(nBytes)
if n.BitLen() < minRSAModulusBits {
return nil, fmt.Errorf("cannot convert jwk to rsa public key: modulus is %d bits, minimum is %d", n.BitLen(), minRSAModulusBits)
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
N: n,
E: int(e.Int64()),
}, nil
}
@@ -159,6 +169,15 @@ func VerifyJWT(raw string, pubKey *rsa.PublicKey) ([]byte, error) {
return nil, fmt.Errorf("cannot decode jwt header: %w", err)
}
var headerFields map[string]json.RawMessage
if err := json.Unmarshal(headerJSON, &headerFields); err != nil {
return nil, fmt.Errorf("cannot parse jwt header: %w", err)
}
if _, ok := headerFields["crit"]; ok {
return nil, fmt.Errorf("cannot verify jwt: unsupported critical header parameter")
}
var header JWTHeader
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, fmt.Errorf("cannot parse jwt header: %w", err)

View File

@@ -33,6 +33,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
)
@@ -54,7 +55,7 @@ func newTestS3Service(t *testing.T, handler http.HandlerFunc) *filemanager.Servi
},
)
return filemanager.NewService(nil, nil, s3Client)
return filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
}
func TestOpenFile_StreamsBody(t *testing.T) {

View File

@@ -28,6 +28,7 @@ import (
"net/http"
"strconv"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
@@ -62,7 +63,7 @@ func (s *Service) ServePublicFile(
obj, err := s.OpenFile(ctx, file, conds)
if err != nil {
return err
return fmt.Errorf("cannot open public file: %w", err)
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
@@ -99,7 +100,12 @@ func (s *Service) ServePublicFile(
}
if _, err := io.Copy(w, obj.Body); err != nil {
return err
// The response status and headers are already written at this point,
// so returning the error would make the caller render a JSON 500
// body into an already-started (and possibly partial) response.
// Log it and stop instead.
s.logger.ErrorCtx(ctx, "cannot stream public file", log.Error(err), log.String("file_id", fileID.String()))
return nil
}
return nil

View File

@@ -22,6 +22,7 @@ package filemanager
import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/baseurl"
)
@@ -30,16 +31,19 @@ type Service struct {
pg *pg.Client
baseURL *baseurl.BaseURL
s3Client *awss3.Client
logger *log.Logger
}
func NewService(
pgClient *pg.Client,
baseURL *baseurl.BaseURL,
s3Client *awss3.Client,
logger *log.Logger,
) *Service {
return &Service{
pg: pgClient,
baseURL: baseURL,
s3Client: s3Client,
logger: logger,
}
}

View File

@@ -22,6 +22,7 @@ package filemanager_test
import (
"context"
"io"
"net/url"
"testing"
"time"
@@ -31,6 +32,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
@@ -45,7 +47,7 @@ func TestGenerateFileURL_PublicFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err)
}
svc := filemanager.NewService(nil, base, nil)
svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPublic,
@@ -66,7 +68,7 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err)
}
svc := filemanager.NewService(nil, base, nil)
svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPrivate,
@@ -88,7 +90,7 @@ func TestGeneratePresignedURL_EscapesContentDispositionFilename(t *testing.T) {
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
},
)
svc := filemanager.NewService(nil, nil, s3Client)
svc := filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
BucketName: "uploads",
FileKey: "tenant/file",

View File

@@ -580,6 +580,19 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
return fmt.Errorf("cannot generate magic link token: %w", err)
}
senderName := magicLinkDefaultSenderName
if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
if err != nil {
return fmt.Errorf("cannot load oauth2 client branding: %w", err)
}
if branding != nil {
senderName = branding.Name
}
}
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
@@ -596,7 +609,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
fullName := req.Email.Username()
identity := &coredata.Identity{}
senderName := magicLinkDefaultSenderName
if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil {
if identity.FullName != "" {
@@ -608,17 +620,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
}
}
if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
if err != nil {
return fmt.Errorf("cannot load oauth2 client branding: %w", err)
}
if branding != nil {
senderName = branding.Name
}
}
emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
if req.MagicLinkBaseURL != nil {

View File

@@ -26,7 +26,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
@@ -117,25 +116,6 @@ func NewIDTokenClaims(
return claims
}
func ParseIDTokenClaims(raw string) (*IDTokenClaims, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("cannot parse id token: invalid format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("cannot parse id token payload: %w", err)
}
var claims IDTokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("cannot decode id token claims: %w", err)
}
return &claims, nil
}
func ParseIDTokenIdentity(
raw string,
jwks *jose.JWKS,

View File

@@ -756,28 +756,34 @@ func (s *OrganizationService) CreateOrganization(
return fmt.Errorf("cannot insert mailing list: %w", err)
}
defaultDomainHostname := trustCenter.Slug + "." + s.trustCenterBaseDomain
// Self-managed installs without a configured base domain don't get
// a default managed domain: there is no suffix to mint a
// "{slug}." hostname from, so the compliance page stays without
// a domain until the organization adds a custom one.
if s.trustCenterBaseDomain != "" {
defaultDomainHostname := trustCenter.Slug + "." + s.trustCenterBaseDomain
defaultDomain := coredata.NewCustomDomain(
tenantID,
organization.ID,
defaultDomainHostname,
true,
)
defaultDomain := coredata.NewCustomDomain(
tenantID,
organization.ID,
defaultDomainHostname,
true,
)
certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
if err != nil {
return fmt.Errorf("cannot ensure certificate for default custom domain: %w", err)
certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
if err != nil {
return fmt.Errorf("cannot ensure certificate for default custom domain: %w", err)
}
defaultDomain.CertificateID = &certificate.ID
if err := defaultDomain.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert default custom domain: %w", err)
}
trustCenter.DefaultDomainID = &defaultDomain.ID
}
defaultDomain.CertificateID = &certificate.ID
if err := defaultDomain.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert default custom domain: %w", err)
}
trustCenter.DefaultDomainID = &defaultDomain.ID
if err := trustCenter.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center: %w", err)
}

View File

@@ -62,7 +62,6 @@ type (
magicLinkTokenValidity time.Duration
sessionDuration time.Duration
bucket string
encryptionKey cipher.EncryptionKey
trustCenterBaseDomain string
certManager *certmanager.Service
certificate *x509.Certificate
@@ -150,6 +149,10 @@ func NewService(
return nil, fmt.Errorf("oauth2 scope registry is required")
}
if cfg.CertManager == nil {
return nil, fmt.Errorf("cert manager is required")
}
svc := &Service{
pg: pgClient,
fm: fm,
@@ -163,7 +166,6 @@ func NewService(
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
encryptionKey: cfg.EncryptionKey,
trustCenterBaseDomain: cfg.TrustCenterBaseDomain,
certManager: cfg.CertManager,
certificate: cfg.Certificate,

View File

@@ -378,7 +378,7 @@ func (impl *Implm) Run(
return err
}
fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client)
fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client, l)
commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService)
if err != nil {

View File

@@ -16,6 +16,7 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/validator"
)
// UpdateFullName is the resolver for the updateFullName field.
@@ -25,43 +26,66 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access")
}
identity, err := r.iam.AccountService.UpdateIdentity(
ctx,
identity.ID,
&iam.UpdateIdentityRequest{
FullName: input.FullName,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
compliancePage := complianceportal.CompliancePageFromContext(ctx)
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
if err != nil {
// External trust-center visitors have no organization profile; updating
// the identity's full name above is all that is needed for them.
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
return &types.UpdateFullNamePayload{Success: true}, nil
// External trust-center visitors have no organization profile; only
// their identity needs updating.
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok {
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
profile = nil
}
if profile.Source == coredata.ProfileSourceManual {
if _, err := r.iam.OrganizationService.UpdateUser(ctx, &iam.UpdateUserRequest{
// The identity and profile full names are validated by different rules.
// Validate the profile update up front so it cannot fail after the
// identity has already been mutated, keeping the two in sync.
var updateUserRequest *iam.UpdateUserRequest
if profile != nil && profile.Source == coredata.ProfileSourceManual {
updateUserRequest = &iam.UpdateUserRequest{
ID: profile.ID,
FullName: identity.FullName,
FullName: input.FullName,
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
Kind: profile.Kind,
Position: profile.Position,
ContractStartDate: &profile.ContractStartDate,
ContractEndDate: &profile.ContractEndDate,
}); err != nil {
}
if err := updateUserRequest.Validate(); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot validate profile update", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
}
if _, err := r.iam.AccountService.UpdateIdentity(
ctx,
identity.ID,
&iam.UpdateIdentityRequest{
FullName: input.FullName,
},
); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if updateUserRequest != nil {
if _, err := r.iam.OrganizationService.UpdateUser(ctx, updateUserRequest); err != nil {
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}

View File

@@ -104,7 +104,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) {
func(r chi.Router) {
r.Use(complianceportal.NewCompliancePagePresenceMiddleware())
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler())
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler(cfg.Visitor))
r.Method(http.MethodGet, complianceportal.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler)

View File

@@ -100,7 +100,15 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, portal.ID, *portalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -108,7 +116,7 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)

View File

@@ -23,22 +23,32 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
)
type oauthClientMetadataHandler struct{}
type oauthClientMetadataHandler struct {
visitor *visitor.Service
}
func NewOAuthClientMetadataHandler() http.Handler {
return &oauthClientMetadataHandler{}
func NewOAuthClientMetadataHandler(visitorSvc *visitor.Service) http.Handler {
return &oauthClientMetadataHandler{visitor: visitorSvc}
}
func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
compliancePage := complianceportal.CompliancePageFromContext(r.Context())
baseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context())
ctx := r.Context()
compliancePage := complianceportal.CompliancePageFromContext(ctx)
baseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
if compliancePage == nil || baseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
doc, err := visitor.BuildClientMetadataDocument(compliancePage, *baseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *baseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
doc, err := visitor.BuildClientMetadataDocument(compliancePage, canonicalBaseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return

View File

@@ -58,8 +58,9 @@ func NewOAuthInitiateHandler(
func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
compliancePage := complianceportal.CompliancePageFromContext(ctx)
portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
if portalBaseURL == nil {
if compliancePage == nil || portalBaseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
@@ -75,7 +76,15 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *portalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
}
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -83,7 +92,7 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)

View File

@@ -45,7 +45,7 @@ type Query {
type OAuthClientBranding {
name: String!
logo: File
logoUrl: String
clientURL: String
}

View File

@@ -25,7 +25,6 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
@@ -39,7 +38,6 @@ import (
func NewGraphQLHandler(
svc *iam.Service,
trustSvc *visitor.Service,
logger *log.Logger,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
@@ -52,7 +50,6 @@ func NewGraphQLHandler(
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
logger: logger,
iam: svc,
trust: trustSvc,
scopeRegistry: svc.OAuth2ScopeRegistry,
fileManager: fileManagerSvc,
baseURL: baseURL,

View File

@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
}
if branding.LogoURL != nil {
result.Logo = &types.File{
DownloadURL: *branding.LogoURL,
}
result.LogoURL = branding.LogoURL
}
return result, nil

View File

@@ -68,7 +68,6 @@ type (
batchAuthorize authz.BatchAuthorizeFunc
logger *log.Logger
iam *iam.Service
trust *visitor.Service
scopeRegistry *oauth2scope.Registry
fileManager *filemanager.Service
baseURL *baseurl.BaseURL
@@ -93,7 +92,7 @@ func NewMux(
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
graphqlHandler := NewGraphQLHandler(svc, trustSvc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))

View File

@@ -1,16 +1,22 @@
// 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.
// 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 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.
// 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 server

View File

@@ -46,6 +46,8 @@ func Make(s string) string {
}
func MakeWithEntropy(s string) string {
const maxDNSLabel = 63
base := Make(s)
suffix := rand.MustHexString(4)
@@ -53,5 +55,18 @@ func MakeWithEntropy(s string) string {
return suffix
}
// DNS labels are capped at 63 octets. Keep the entropy suffix and
// truncate the name-derived prefix so hostnames stay provisionable.
maxBase := maxDNSLabel - 1 - len(suffix)
if maxBase < 1 {
return suffix
}
if len(base) > maxBase {
base = strings.Trim(base[:maxBase], "-")
if base == "" {
return suffix
}
}
return base + "-" + suffix
}

View File

@@ -21,6 +21,7 @@
package slug
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -91,4 +92,15 @@ func TestMakeWithEntropy(t *testing.T) {
assert.NotEqual(t, first, second, "MakeWithEntropy should produce distinct slugs")
},
)
t.Run(
"long names stay within dns label length",
func(t *testing.T) {
t.Parallel()
got := MakeWithEntropy(strings.Repeat("Very Long Organization Name ", 10))
assert.LessOrEqual(t, len(got), 63)
assert.Regexp(t, `^[a-z0-9-]+-[0-9a-f]{8}$`, got)
},
)
}