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

View File

@@ -30,9 +30,9 @@ import {
useDialogRef, useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql"; import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
import { deleteTrustCenterReferenceMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
import { useMutation } from "#/lib/relay/useMutation"; import { useMutation } from "#/lib/relay/useMutation";
import { deleteCompliancePageReferenceMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@@ -52,8 +52,8 @@ export function DeleteCompliancePageReferenceDialog({
const { __ } = useTranslate(); const { __ } = useTranslate();
const ref = useDialogRef(); const ref = useDialogRef();
const [mutate, isDeleting] = useMutation<TrustCenterReferenceGraphDeleteMutation>( const [mutate, isDeleting] = useMutation<compliancePageReferenceMutationsDeleteMutation>(
deleteTrustCenterReferenceMutation, deleteCompliancePageReferenceMutation,
{ {
successMessage: __("Reference deleted successfully"), successMessage: __("Reference deleted successfully"),
errorToast: __("Failed to delete reference"), 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>. // Copyright (c) 2026 Probo Inc <hello@probo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission is hereby granted, free of charge, to any person obtaining a copy
// purpose with or without fee is hereby granted, provided that the above // of this software and associated documentation files (the "Software"), to deal
// copyright notice and this permission notice appear in all copies. // 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 // The above copyright notice and this permission notice shall be included in
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // all copies or substantial portions of the Software.
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, //
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// PERFORMANCE OF THIS SOFTWARE. // 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui"; import { Button } from "@probo/ui";
import { useNavigate } from "react-router";
export default function MagicLinkAlreadyUsedPage() { export default function MagicLinkAlreadyUsedPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate();
usePageTitle(__("Link Already Used")); usePageTitle(__("Link Already Used"));
@@ -33,10 +37,7 @@ export default function MagicLinkAlreadyUsedPage() {
)} )}
</p> </p>
</div> </div>
<Button <Button className="w-full h-10" to="/auth/login">
className="w-full h-10"
onClick={() => void navigate("/auth/login")}
>
{__("Sign in")} {__("Sign in")}
</Button> </Button>
</div> </div>

View File

@@ -1,25 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>. // Copyright (c) 2026 Probo Inc <hello@probo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission is hereby granted, free of charge, to any person obtaining a copy
// purpose with or without fee is hereby granted, provided that the above // of this software and associated documentation files (the "Software"), to deal
// copyright notice and this permission notice appear in all copies. // 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 // The above copyright notice and this permission notice shall be included in
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // all copies or substantial portions of the Software.
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, //
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// PERFORMANCE OF THIS SOFTWARE. // 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui"; import { Button } from "@probo/ui";
import { useNavigate } from "react-router";
export default function MagicLinkExpiredPage() { export default function MagicLinkExpiredPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate();
usePageTitle(__("Link Expired")); usePageTitle(__("Link Expired"));
@@ -33,10 +37,7 @@ export default function MagicLinkExpiredPage() {
)} )}
</p> </p>
</div> </div>
<Button <Button className="w-full h-10" to="/auth/login">
className="w-full h-10"
onClick={() => void navigate("/auth/login")}
>
{__("Sign in")} {__("Sign in")}
</Button> </Button>
</div> </div>

View File

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

View File

@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>. // Copyright (c) 2026 Probo Inc <hello@probo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission is hereby granted, free of charge, to any person obtaining a copy
// purpose with or without fee is hereby granted, provided that the above // of this software and associated documentation files (the "Software"), to deal
// copyright notice and this permission notice appear in all copies. // 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 // The above copyright notice and this permission notice shall be included in
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // all copies or substantial portions of the Software.
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, //
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// PERFORMANCE OF THIS SOFTWARE. // 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 { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui"; import { Button, Field, useToast } from "@probo/ui";
@@ -67,12 +73,22 @@ export function MagicLinkForm() {
body.set("email", email); body.set("email", email);
body.set("continue", postAuthRedirectUrl); body.set("continue", postAuthRedirectUrl);
const response = await fetch("/api/connect/v1/magic-link/send", { let response: Response;
try {
response = await fetch("/api/connect/v1/magic-link/send", {
method: "POST", method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/x-www-form-urlencoded" },
credentials: "include", credentials: "include",
body, body,
}); });
} catch {
toast({
title: __("Error"),
description: __("Cannot send magic link"),
variant: "error",
});
return;
}
if (!response.ok) { if (!response.ok) {
toast({ 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 { CompliancePageReferenceListFragment$key } from "#/__generated__/core/CompliancePageReferenceListFragment.graphql";
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql"; import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.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"; import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
@@ -65,7 +65,7 @@ export function CompliancePageReferenceList(props: {
CompliancePageReferenceListQuery, CompliancePageReferenceListQuery,
CompliancePageReferenceListFragment$key CompliancePageReferenceListFragment$key
>(fragment, fragmentRef); >(fragment, fragmentRef);
const [updateRank] = useUpdateTrustCenterReferenceRankMutation(); const [updateRank] = useUpdateCompliancePageReferenceRankMutation();
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);

View File

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

View File

@@ -39,8 +39,9 @@ import (
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center // trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
// HTTPS listener started by the e2e probod (see generateConfig). Compliance // HTTPS listener started by the e2e probod (see generateConfig). Compliance
// pages are served here exclusively, routed by TLS SNI / Host header. // pages are served here exclusively, routed by TLS SNI / Host header. Uses a
const trustCenterHTTPSAddr = "127.0.0.1:443" // 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 { type GraphQLRequest struct {
Query string `json:"query"` Query string `json:"query"`

View File

@@ -298,7 +298,7 @@ func generateConfig() (string, error) {
// yields {slug}.probopage.localhost subdomains for pages without a // yields {slug}.probopage.localhost subdomains for pages without a
// customer custom domain. // customer custom domain.
"PROBOD_TRUST_CENTER_HTTP_ADDR": ":10080", "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", "PROBOD_TRUST_CENTER_BASE_DOMAIN": "probopage.localhost",
// Keep certificate provisioning snappy so trust-center e2e flows do not // 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( err := w.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { 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 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) 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) 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 { if loadedCertificate.SSLExpiresAt == nil {
return fmt.Errorf("certificate has no expiry date") 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") return fmt.Errorf("certificate has expired")
} }
cache := &coredata.CachedCertificate{ var cache coredata.CachedCertificate
Domain: loadedCertificate.Hostname, if err := cache.RefreshFromCertificate(ctx, conn, &loadedCertificate, w.encryptionKey); err != nil {
CertificatePEM: string(loadedCertificate.SSLCertificatePEM), return fmt.Errorf("cannot refresh certificate cache: %w", err)
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)
} }
return nil 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 { func(ctx context.Context, tx pg.Tx) error {
fullCertificate := &coredata.Certificate{} fullCertificate := &coredata.Certificate{}
if err := fullCertificate.LoadByIDForUpdateSkipLocked(ctx, tx, coredata.NewNoScope(), certificate.ID); err != nil { 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) return fmt.Errorf("cannot load certificate for renewal: %w", err)
} }

View File

@@ -26,7 +26,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"sync" "sync"
"time"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -68,8 +67,15 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
if cached, ok := s.cache.Load(domain); ok { if cached, ok := s.cache.Load(domain); ok {
if cert, ok := cached.(*tls.Certificate); ok { if cert, ok := cached.(*tls.Certificate); ok {
if err := s.checkRoutable(domain); err == nil {
return cert, 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)
}
} }
cert, err := s.loadFromDatabase(domain) cert, err := s.loadFromDatabase(domain)
@@ -82,6 +88,21 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return cert, nil 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) { func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) {
ctx := context.Background() 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") return fmt.Errorf("certificate has no encrypted private key data")
} }
privateKeyPEM, err := certificate.DecryptPrivateKey(s.encryptionKey) if certificate.SSLExpiresAt == nil {
if err != nil { return fmt.Errorf("certificate has no expiry")
return fmt.Errorf("cannot decrypt private key: %w", err)
} }
s.cache.Store(domain, certificate.SSLCertificate) s.cache.Store(domain, certificate.SSLCertificate)
cache := &coredata.CachedCertificate{ var cache coredata.CachedCertificate
Domain: certificate.Hostname, if err := cache.RefreshFromCertificate(ctx, conn, &certificate, s.encryptionKey); err != nil {
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 {
return fmt.Errorf("cannot insert cache entry: %w", err) return fmt.Errorf("cannot insert cache entry: %w", err)
} }
return nil 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 { func requireRoutableDomain(ctx context.Context, conn pg.Querier, domain string) error {
var customDomain coredata.CustomDomain var customDomain coredata.CustomDomain
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil { if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {

View File

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

View File

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

View File

@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>. // Copyright (c) 2026 Probo Inc <hello@probo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission is hereby granted, free of charge, to any person obtaining a copy
// purpose with or without fee is hereby granted, provided that the above // of this software and associated documentation files (the "Software"), to deal
// copyright notice and this permission notice appear in all copies. // 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 // The above copyright notice and this permission notice shall be included in
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // all copies or substantial portions of the Software.
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, //
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// PERFORMANCE OF THIS SOFTWARE. // 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 package management
@@ -18,16 +24,11 @@ import (
"go.probo.inc/probo/pkg/coredata" "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 ( const (
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read" ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page" ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
) )
// OAuth2ScopeMappings maps the compliance portal OAuth2 scopes to the actions
// they grant.
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ScopeV1CompliancePortalRead: { ScopeV1CompliancePortalRead: {
ActionCompliancePortalGet, ActionCompliancePortalGet,
@@ -45,6 +46,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList, ActionComplianceFrameworkList,
ActionComplianceCustomLinkList, ActionComplianceCustomLinkList,
ActionCustomDomainGet, ActionCustomDomainGet,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
}, },
ScopeV1CompliancePortal: { ScopeV1CompliancePortal: {
ActionCompliancePortalGet, ActionCompliancePortalGet,
@@ -62,6 +65,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList, ActionComplianceFrameworkList,
ActionComplianceCustomLinkList, ActionComplianceCustomLinkList,
ActionCustomDomainGet, ActionCustomDomainGet,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
ActionCompliancePortalUpdate, ActionCompliancePortalUpdate,
ActionCompliancePortalNonDisclosureAgreementUpload, ActionCompliancePortalNonDisclosureAgreementUpload,
ActionCompliancePortalNonDisclosureAgreementDelete, ActionCompliancePortalNonDisclosureAgreementDelete,
@@ -89,5 +94,13 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceCustomLinkDelete, ActionComplianceCustomLinkDelete,
ActionCustomDomainCreate, ActionCustomDomainCreate,
ActionCustomDomainDelete, ActionCustomDomainDelete,
ActionCompliancePortalCommitmentGroupCreate,
ActionCompliancePortalCommitmentGroupUpdate,
ActionCompliancePortalCommitmentGroupUpdateRank,
ActionCompliancePortalCommitmentGroupDelete,
ActionCompliancePortalCommitmentCreate,
ActionCompliancePortalCommitmentUpdate,
ActionCompliancePortalCommitmentUpdateRank,
ActionCompliancePortalCommitmentDelete,
}, },
} }

View File

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

View File

@@ -14,33 +14,15 @@
package visitor package visitor
import (
"fmt"
"net/url"
)
const ( const (
BrandLogoPath = "/brand/logo" BrandLogoPath = "/brand/logo"
BrandDarkLogoPath = "/brand/dark-logo" BrandDarkLogoPath = "/brand/dark-logo"
) )
func BrandLogoURL(portalBaseURL string) (string, error) { func BrandLogoURL(portalBaseURL string) (string, error) {
return brandAssetURL(portalBaseURL, BrandLogoPath) return portalEndpointURL(portalBaseURL, BrandLogoPath)
} }
func BrandDarkLogoURL(portalBaseURL string) (string, error) { func BrandDarkLogoURL(portalBaseURL string) (string, error) {
return brandAssetURL(portalBaseURL, BrandDarkLogoPath) return portalEndpointURL(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
} }

View File

@@ -29,38 +29,26 @@ const (
) )
func CIMDClientIDURL(portalBaseURL string) (string, error) { func CIMDClientIDURL(portalBaseURL string) (string, error) {
parsed, err := url.Parse(portalBaseURL) return portalEndpointURL(portalBaseURL, CIMDMetadataPath)
if err != nil {
return "", err
}
parsed.Path = CIMDMetadataPath
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
} }
func OAuthCallbackURL(portalBaseURL string) (string, error) { func OAuthCallbackURL(portalBaseURL string) (string, error) {
parsed, err := url.Parse(portalBaseURL) return portalEndpointURL(portalBaseURL, OAuthCallbackPath)
if err != nil {
return "", err
}
parsed.Path = OAuthCallbackPath
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
} }
func PortalRootURL(rawURL string) (string, error) { 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 { if err != nil {
return "", fmt.Errorf("cannot parse portal URL: %w", err) return "", fmt.Errorf("cannot parse portal URL: %w", err)
} }
parsed.Path = "" parsed.Path = path
parsed.RawQuery = "" parsed.RawQuery = ""
parsed.Fragment = "" parsed.Fragment = ""

View File

@@ -55,28 +55,3 @@ func (s *Service) ListCommitmentGroupsForPortalID(
return page.NewPage(groups, cursor), nil 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" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
@@ -153,6 +154,38 @@ func (s *Service) GetPortalEffectiveCanonicalHost(ctx context.Context, complianc
return host, nil 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) { func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
compliancePage := &coredata.TrustCenter{} compliancePage := &coredata.TrustCenter{}

View File

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

View File

@@ -377,3 +377,32 @@ WHERE
return nil 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, tenant_id,
active, active,
slug, slug,
search_engine_indexing,
created_at, created_at,
updated_at updated_at
) )
@@ -41,6 +42,7 @@ SELECT
'\s+', '-', 'g' '\s+', '-', 'g'
) )
), ),
'NOT_INDEXABLE',
NOW(), NOW(),
NOW() NOW()
FROM organizations o FROM organizations o

View File

@@ -25,7 +25,7 @@ CREATE TABLE certificates (
ssl_certificate_chain TEXT, ssl_certificate_chain TEXT,
status custom_domain_ssl_status NOT NULL, status custom_domain_ssl_status NOT NULL,
ssl_expires_at TIMESTAMP WITH TIME ZONE, 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, ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
http_challenge_token TEXT, http_challenge_token TEXT,
http_challenge_key_auth TEXT, http_challenge_key_auth TEXT,
@@ -100,6 +100,12 @@ SET certificate_id = cd.certificate_id
FROM custom_domains cd FROM custom_domains cd
WHERE cd.id = cc.custom_domain_id; 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; ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
-- Drop the certificate lifecycle columns now living on certificates. -- Drop the certificate lifecycle columns now living on certificates.

View File

@@ -29,6 +29,19 @@ WITH pending_pages AS (
FROM trust_centers tc FROM trust_centers tc
WHERE tc.default_domain_id IS NULL WHERE tc.default_domain_id IS NULL
AND NULLIF(current_setting('probo.trust_center_base_domain', true), '') IS NOT 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 ( minted_certificates AS (
INSERT INTO certificates ( INSERT INTO certificates (

View File

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

View File

@@ -32,6 +32,11 @@ import (
"strings" "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 ( type (
// JWK represents a JSON Web Key (RFC 7517). // JWK represents a JSON Web Key (RFC 7517).
JWK struct { 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") 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{ return &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes), N: n,
E: int(e.Int64()), E: int(e.Int64()),
}, nil }, nil
} }
@@ -159,6 +169,15 @@ func VerifyJWT(raw string, pubKey *rsa.PublicKey) ([]byte, error) {
return nil, fmt.Errorf("cannot decode jwt header: %w", err) 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 var header JWTHeader
if err := json.Unmarshal(headerJSON, &header); err != nil { if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, fmt.Errorf("cannot parse jwt header: %w", err) 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" awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager" "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) { func TestOpenFile_StreamsBody(t *testing.T) {

View File

@@ -28,6 +28,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
) )
@@ -62,7 +63,7 @@ func (s *Service) ServePublicFile(
obj, err := s.OpenFile(ctx, file, conds) obj, err := s.OpenFile(ctx, file, conds)
if err != nil { if err != nil {
return err return fmt.Errorf("cannot open public file: %w", err)
} }
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 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 { 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 return nil

View File

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

View File

@@ -22,6 +22,7 @@ package filemanager_test
import ( import (
"context" "context"
"io"
"net/url" "net/url"
"testing" "testing"
"time" "time"
@@ -31,6 +32,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3" awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/filemanager"
@@ -45,7 +47,7 @@ func TestGenerateFileURL_PublicFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err) 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{ file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType), ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPublic, Visibility: coredata.FileVisibilityPublic,
@@ -66,7 +68,7 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err) 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{ file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType), ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPrivate, Visibility: coredata.FileVisibilityPrivate,
@@ -88,7 +90,7 @@ func TestGeneratePresignedURL_EscapesContentDispositionFilename(t *testing.T) {
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""), 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{ file := &coredata.File{
BucketName: "uploads", BucketName: "uploads",
FileKey: "tenant/file", 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) 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( return s.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { 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() fullName := req.Email.Username()
identity := &coredata.Identity{} identity := &coredata.Identity{}
senderName := magicLinkDefaultSenderName
if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil { if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil {
if identity.FullName != "" { 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) emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
if req.MagicLinkBaseURL != nil { if req.MagicLinkBaseURL != nil {

View File

@@ -26,7 +26,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -117,25 +116,6 @@ func NewIDTokenClaims(
return claims 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( func ParseIDTokenIdentity(
raw string, raw string,
jwks *jose.JWKS, jwks *jose.JWKS,

View File

@@ -756,6 +756,11 @@ func (s *OrganizationService) CreateOrganization(
return fmt.Errorf("cannot insert mailing list: %w", err) return fmt.Errorf("cannot insert mailing list: %w", err)
} }
// 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 defaultDomainHostname := trustCenter.Slug + "." + s.trustCenterBaseDomain
defaultDomain := coredata.NewCustomDomain( defaultDomain := coredata.NewCustomDomain(
@@ -777,6 +782,7 @@ func (s *OrganizationService) CreateOrganization(
} }
trustCenter.DefaultDomainID = &defaultDomain.ID trustCenter.DefaultDomainID = &defaultDomain.ID
}
if err := trustCenter.Insert(ctx, tx, scope); err != nil { if err := trustCenter.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center: %w", err) return fmt.Errorf("cannot insert trust center: %w", err)

View File

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

View File

@@ -378,7 +378,7 @@ func (impl *Implm) Run(
return err return err
} }
fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client) fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client, l)
commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService) commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService)
if err != nil { 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"
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types" "go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils" "go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/validator"
) )
// UpdateFullName is the resolver for the updateFullName field. // 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") 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) compliancePage := complianceportal.CompliancePageFromContext(ctx)
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID) profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
if err != nil { if err != nil {
// External trust-center visitors have no organization profile; updating // External trust-center visitors have no organization profile; only
// the identity's full name above is all that is needed for them. // their identity needs updating.
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok { if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok {
return &types.UpdateFullNamePayload{Success: true}, nil
}
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err)) r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
if profile.Source == coredata.ProfileSourceManual { profile = nil
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, ID: profile.ID,
FullName: identity.FullName, FullName: input.FullName,
AdditionalEmailAddresses: profile.AdditionalEmailAddresses, AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
Kind: profile.Kind, Kind: profile.Kind,
Position: profile.Position, Position: profile.Position,
ContractStartDate: &profile.ContractStartDate, ContractStartDate: &profile.ContractStartDate,
ContractEndDate: &profile.ContractEndDate, 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)) r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }

View File

@@ -104,7 +104,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) {
func(r chi.Router) { func(r chi.Router) {
r.Use(complianceportal.NewCompliancePagePresenceMiddleware()) 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.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File)) r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler) r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler)

View File

@@ -100,7 +100,15 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return 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 { if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err)) h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal) httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -108,7 +116,7 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return return
} }
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL) redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil { if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err)) h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal) httpserver.RenderError(w, http.StatusInternalServerError, errInternal)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>. // Copyright (c) 2026 Probo Inc <hello@probo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission is hereby granted, free of charge, to any person obtaining a copy
// purpose with or without fee is hereby granted, provided that the above // of this software and associated documentation files (the "Software"), to deal
// copyright notice and this permission notice appear in all copies. // 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 // The above copyright notice and this permission notice shall be included in
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // all copies or substantial portions of the Software.
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, //
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// PERFORMANCE OF THIS SOFTWARE. // 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 package server

View File

@@ -46,6 +46,8 @@ func Make(s string) string {
} }
func MakeWithEntropy(s string) string { func MakeWithEntropy(s string) string {
const maxDNSLabel = 63
base := Make(s) base := Make(s)
suffix := rand.MustHexString(4) suffix := rand.MustHexString(4)
@@ -53,5 +55,18 @@ func MakeWithEntropy(s string) string {
return suffix 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 return base + "-" + suffix
} }

View File

@@ -21,6 +21,7 @@
package slug package slug
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -91,4 +92,15 @@ func TestMakeWithEntropy(t *testing.T) {
assert.NotEqual(t, first, second, "MakeWithEntropy should produce distinct slugs") 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)
},
)
} }