diff --git a/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx b/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx index 73044fe67..d814f68b8 100644 --- a/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx +++ b/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx @@ -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(null); const [uploadedFile, setUploadedFile] = useState(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, diff --git a/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx b/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx index f9545460e..a929246c1 100644 --- a/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx +++ b/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx @@ -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( - deleteTrustCenterReferenceMutation, + const [mutate, isDeleting] = useMutation( + deleteCompliancePageReferenceMutation, { successMessage: __("Reference deleted successfully"), errorToast: __("Failed to delete reference"), diff --git a/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts b/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts deleted file mode 100644 index 518d12ce4..000000000 --- a/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -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( - createTrustCenterReferenceMutation, - { - successMessage: "Reference created successfully", - errorToast: "Failed to create reference", - }, - ); -} - -export function useUpdateTrustCenterReferenceMutation() { - return useMutation( - 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( - updateTrustCenterReferenceRankMutation, - { - successMessage: "Order updated successfully", - errorToast: "Failed to update order", - }, - ); -} - -export function useDeleteTrustCenterReferenceMutation() { - return useMutation( - deleteTrustCenterReferenceMutation, - { - successMessage: "Reference deleted successfully", - errorToast: "Failed to delete reference", - }, - ); -} diff --git a/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx index 9dd57c8e5..10f81b405 100644 --- a/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx +++ b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx @@ -1,25 +1,29 @@ // Copyright (c) 2026 Probo Inc . // -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. +// 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() { )}

- diff --git a/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx index 9d5b37cef..1920a8d0e 100644 --- a/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx +++ b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx @@ -1,25 +1,29 @@ // Copyright (c) 2026 Probo Inc . // -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. +// 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() { )}

- diff --git a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx index 80dbceaab..99430d2ef 100644 --- a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx @@ -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) { <>
diff --git a/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx b/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx index fe678ffbc..a9beedba5 100644 --- a/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx @@ -1,16 +1,22 @@ // Copyright (c) 2026 Probo Inc . // -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. +// 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({ diff --git a/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts b/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts new file mode 100644 index 000000000..176b94935 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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( + createCompliancePageReferenceMutation, + { + successMessage: "Reference created successfully", + errorToast: "Failed to create reference", + }, + ); +} + +export function useUpdateCompliancePageReferenceMutation() { + return useMutation( + 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( + updateCompliancePageReferenceRankMutation, + { + successMessage: "Order updated successfully", + errorToast: "Failed to update order", + }, + ); +} + +export function useDeleteCompliancePageReferenceMutation() { + return useMutation( + deleteCompliancePageReferenceMutation, + { + successMessage: "Reference deleted successfully", + errorToast: "Failed to delete reference", + }, + ); +} diff --git a/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx b/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx index 1146b0fbf..ea4e7a6a4 100644 --- a/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx +++ b/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx @@ -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(null); const [dragOverIndex, setDragOverIndex] = useState(null); diff --git a/apps/trust/src/queries/TrustGraph.ts b/apps/trust/src/queries/TrustGraph.ts index 3c5670af1..a55fd7226 100644 --- a/apps/trust/src/queries/TrustGraph.ts +++ b/apps/trust/src/queries/TrustGraph.ts @@ -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 { diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go index 61f196969..763e4c3ea 100644 --- a/e2e/internal/testutil/graphql.go +++ b/e2e/internal/testutil/graphql.go @@ -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"` diff --git a/e2e/internal/testutil/testutil.go b/e2e/internal/testutil/testutil.go index f241626b5..fc26231cd 100644 --- a/e2e/internal/testutil/testutil.go +++ b/e2e/internal/testutil/testutil.go @@ -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 diff --git a/pkg/certmanager/cache_store.go b/pkg/certmanager/cache_store.go index a47fa2632..619392e18 100644 --- a/pkg/certmanager/cache_store.go +++ b/pkg/certmanager/cache_store.go @@ -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 diff --git a/pkg/certmanager/renew_worker.go b/pkg/certmanager/renew_worker.go index 77efb5eaa..673f3a601 100644 --- a/pkg/certmanager/renew_worker.go +++ b/pkg/certmanager/renew_worker.go @@ -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) } diff --git a/pkg/certmanager/selector.go b/pkg/certmanager/selector.go index 379d84805..cf713c280 100644 --- a/pkg/certmanager/selector.go +++ b/pkg/certmanager/selector.go @@ -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 { diff --git a/pkg/cmd/trust-center/update/update.go b/pkg/cmd/trust-center/update/update.go index 8d88d18cb..c56224630 100644 --- a/pkg/cmd/trust-center/update/update.go +++ b/pkg/cmd/trust-center/update/update.go @@ -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") { diff --git a/pkg/complianceportal/management/domain.go b/pkg/complianceportal/management/domain.go index 40d7180a0..5d7867f5d 100644 --- a/pkg/complianceportal/management/domain.go +++ b/pkg/complianceportal/management/domain.go @@ -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 } diff --git a/pkg/complianceportal/management/oauth2_scopes.go b/pkg/complianceportal/management/oauth2_scopes.go index 7e041fa0e..f693be9a8 100644 --- a/pkg/complianceportal/management/oauth2_scopes.go +++ b/pkg/complianceportal/management/oauth2_scopes.go @@ -1,16 +1,22 @@ // Copyright (c) 2026 Probo Inc . // -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. +// 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, }, } diff --git a/pkg/complianceportal/management/policies.go b/pkg/complianceportal/management/policies.go index 5bb40bb65..c572f7e80 100644 --- a/pkg/complianceportal/management/policies.go +++ b/pkg/complianceportal/management/policies.go @@ -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") diff --git a/pkg/complianceportal/visitor/brand.go b/pkg/complianceportal/visitor/brand.go index fca799724..e638c3712 100644 --- a/pkg/complianceportal/visitor/brand.go +++ b/pkg/complianceportal/visitor/brand.go @@ -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) } diff --git a/pkg/complianceportal/visitor/cimd.go b/pkg/complianceportal/visitor/cimd.go index 6f07fbc7f..add7d8969 100644 --- a/pkg/complianceportal/visitor/cimd.go +++ b/pkg/complianceportal/visitor/cimd.go @@ -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 = "" diff --git a/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go b/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go index 31078028f..a2d6d880a 100644 --- a/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go +++ b/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go @@ -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 -} diff --git a/pkg/complianceportal/visitor/service.go b/pkg/complianceportal/visitor/service.go index e6fc22dfe..df228f0cb 100644 --- a/pkg/complianceportal/visitor/service.go +++ b/pkg/complianceportal/visitor/service.go @@ -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{} diff --git a/pkg/coredata/cached_certificate.go b/pkg/coredata/cached_certificate.go index 609d6a062..5046aaa27 100644 --- a/pkg/coredata/cached_certificate.go +++ b/pkg/coredata/cached_certificate.go @@ -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) } diff --git a/pkg/coredata/custom_domain.go b/pkg/coredata/custom_domain.go index e835aafad..694fa5234 100644 --- a/pkg/coredata/custom_domain.go +++ b/pkg/coredata/custom_domain.go @@ -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 +} diff --git a/pkg/coredata/migrations/20260706T132923Z.sql b/pkg/coredata/migrations/20260706T132923Z.sql index 54f1239ea..522575b9e 100644 --- a/pkg/coredata/migrations/20260706T132923Z.sql +++ b/pkg/coredata/migrations/20260706T132923Z.sql @@ -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 diff --git a/pkg/coredata/migrations/20260709T090905Z.sql b/pkg/coredata/migrations/20260709T090905Z.sql index da2ef60dd..2fef87264 100644 --- a/pkg/coredata/migrations/20260709T090905Z.sql +++ b/pkg/coredata/migrations/20260709T090905Z.sql @@ -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. diff --git a/pkg/coredata/migrations/20260710T121004Z.sql b/pkg/coredata/migrations/20260710T121004Z.sql index 832f6ba4b..baac6c604 100644 --- a/pkg/coredata/migrations/20260710T121004Z.sql +++ b/pkg/coredata/migrations/20260710T121004Z.sql @@ -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 ( diff --git a/pkg/coredata/migrations/20260717T121103Z.sql b/pkg/coredata/migrations/20260717T121103Z.sql index 5eab3dfa3..341d1d4f9 100644 --- a/pkg/coredata/migrations/20260717T121103Z.sql +++ b/pkg/coredata/migrations/20260717T121103Z.sql @@ -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(); diff --git a/pkg/crypto/jose/jose.go b/pkg/crypto/jose/jose.go index 23df634b8..e87e0a557 100644 --- a/pkg/crypto/jose/jose.go +++ b/pkg/crypto/jose/jose.go @@ -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) diff --git a/pkg/filemanager/s3_test.go b/pkg/filemanager/s3_test.go index 8497c8f38..f7c1771c1 100644 --- a/pkg/filemanager/s3_test.go +++ b/pkg/filemanager/s3_test.go @@ -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) { diff --git a/pkg/filemanager/serve_public.go b/pkg/filemanager/serve_public.go index 3b6e93604..9feb7108c 100644 --- a/pkg/filemanager/serve_public.go +++ b/pkg/filemanager/serve_public.go @@ -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 diff --git a/pkg/filemanager/service.go b/pkg/filemanager/service.go index bf5dc15ea..12fbe0a73 100644 --- a/pkg/filemanager/service.go +++ b/pkg/filemanager/service.go @@ -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, } } diff --git a/pkg/filemanager/url_test.go b/pkg/filemanager/url_test.go index d2d10d78b..66aa36641 100644 --- a/pkg/filemanager/url_test.go +++ b/pkg/filemanager/url_test.go @@ -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", diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go index ee3945a20..15542bab5 100644 --- a/pkg/iam/auth_service.go +++ b/pkg/iam/auth_service.go @@ -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 { diff --git a/pkg/iam/oauth2/id_token.go b/pkg/iam/oauth2/id_token.go index 8ad58db5d..66a29f2e3 100644 --- a/pkg/iam/oauth2/id_token.go +++ b/pkg/iam/oauth2/id_token.go @@ -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, diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index f11d7d0d0..122faacd6 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -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) } diff --git a/pkg/iam/service.go b/pkg/iam/service.go index b3cfc7f76..00d8afdeb 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -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, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 7fa7482f6..1c6bb41cc 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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 { diff --git a/pkg/server/api/complianceportal/v1/auth_resolvers.go b/pkg/server/api/complianceportal/v1/auth_resolvers.go index c657be49b..67d521d7c 100644 --- a/pkg/server/api/complianceportal/v1/auth_resolvers.go +++ b/pkg/server/api/complianceportal/v1/auth_resolvers.go @@ -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) } diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go index 19b00c192..edfde13f8 100644 --- a/pkg/server/api/complianceportal/v1/mux.go +++ b/pkg/server/api/complianceportal/v1/mux.go @@ -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) diff --git a/pkg/server/api/complianceportal/v1/oauth_callback_handler.go b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go index fa6ab1ce3..145bd799a 100644 --- a/pkg/server/api/complianceportal/v1/oauth_callback_handler.go +++ b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go @@ -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) diff --git a/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go index 1e7cdb1f0..b1cfbc2c9 100644 --- a/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go +++ b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go @@ -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 diff --git a/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go index 963c5cadb..4ba6ef984 100644 --- a/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go +++ b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go @@ -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) diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index b808e69b4..73d92e757 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -45,7 +45,7 @@ type Query { type OAuthClientBranding { name: String! - logo: File + logoUrl: String clientURL: String } diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index a556d9685..e7140cf73 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -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, diff --git a/pkg/server/api/connect/v1/oauth_client_branding.go b/pkg/server/api/connect/v1/oauth_client_branding.go index 213f534c5..d6cb55cbc 100644 --- a/pkg/server/api/connect/v1/oauth_client_branding.go +++ b/pkg/server/api/connect/v1/oauth_client_branding.go @@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM( } if branding.LogoURL != nil { - result.Logo = &types.File{ - DownloadURL: *branding.LogoURL, - } + result.LogoURL = branding.LogoURL } return result, nil diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 3f2e2e22c..e3e69c8ec 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -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")) diff --git a/pkg/server/response_headers.go b/pkg/server/response_headers.go index a9a78c6ea..d4b0ec724 100644 --- a/pkg/server/response_headers.go +++ b/pkg/server/response_headers.go @@ -1,16 +1,22 @@ // Copyright (c) 2026 Probo Inc . // -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. +// 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 diff --git a/pkg/slug/slug.go b/pkg/slug/slug.go index 6fc09fd7a..1394235dc 100644 --- a/pkg/slug/slug.go +++ b/pkg/slug/slug.go @@ -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 } diff --git a/pkg/slug/slug_test.go b/pkg/slug/slug_test.go index dbe9ca95d..b7c8eb308 100644 --- a/pkg/slug/slug_test.go +++ b/pkg/slug/slug_test.go @@ -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) + }, + ) }