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:
@@ -35,11 +35,11 @@ import { forwardRef, type ReactNode, useImperativeHandle, useState } from "react
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
||||
import {
|
||||
useCreateTrustCenterReferenceMutation,
|
||||
useUpdateTrustCenterReferenceMutation,
|
||||
} from "#/hooks/graph/TrustCenterReferenceGraph";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import {
|
||||
useCreateCompliancePageReferenceMutation,
|
||||
useUpdateCompliancePageReferenceMutation,
|
||||
} from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||
|
||||
const referenceSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -65,8 +65,8 @@ export const CompliancePageReferenceDialog = forwardRef<CompliancePageReferenceD
|
||||
const [editReference, setEditReference] = useState<CompliancePageReferenceListItemFragment$data | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
|
||||
const [createReference, isCreating] = useCreateTrustCenterReferenceMutation();
|
||||
const [updateReference, isUpdating] = useUpdateTrustCenterReferenceMutation();
|
||||
const [createReference, isCreating] = useCreateCompliancePageReferenceMutation();
|
||||
const [updateReference, isUpdating] = useUpdateCompliancePageReferenceMutation();
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(
|
||||
referenceSchema,
|
||||
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
|
||||
import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
|
||||
import { deleteTrustCenterReferenceMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
|
||||
import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
import { deleteCompliancePageReferenceMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -52,8 +52,8 @@ export function DeleteCompliancePageReferenceDialog({
|
||||
const { __ } = useTranslate();
|
||||
const ref = useDialogRef();
|
||||
|
||||
const [mutate, isDeleting] = useMutation<TrustCenterReferenceGraphDeleteMutation>(
|
||||
deleteTrustCenterReferenceMutation,
|
||||
const [mutate, isDeleting] = useMutation<compliancePageReferenceMutationsDeleteMutation>(
|
||||
deleteCompliancePageReferenceMutation,
|
||||
{
|
||||
successMessage: __("Reference deleted successfully"),
|
||||
errorToast: __("Failed to delete reference"),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button } from "@probo/ui";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function MagicLinkAlreadyUsedPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
usePageTitle(__("Link Already Used"));
|
||||
|
||||
@@ -33,10 +37,7 @@ export default function MagicLinkAlreadyUsedPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
onClick={() => void navigate("/auth/login")}
|
||||
>
|
||||
<Button className="w-full h-10" to="/auth/login">
|
||||
{__("Sign in")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button } from "@probo/ui";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function MagicLinkExpiredPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
usePageTitle(__("Link Expired"));
|
||||
|
||||
@@ -33,10 +37,7 @@ export default function MagicLinkExpiredPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
onClick={() => void navigate("/auth/login")}
|
||||
>
|
||||
<Button className="w-full h-10" to="/auth/login">
|
||||
{__("Sign in")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,7 @@ export const signInPageQuery = graphql`
|
||||
oauthClientBranding(clientId: $clientId) {
|
||||
name
|
||||
clientURL
|
||||
logo {
|
||||
downloadUrl
|
||||
}
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -85,7 +83,7 @@ export default function SignInPage(props: Props) {
|
||||
<>
|
||||
<OAuthClientBrandingSection
|
||||
name={clientBranding.name}
|
||||
logoDownloadUrl={clientBranding.logo?.downloadUrl}
|
||||
logoDownloadUrl={clientBranding.logoUrl}
|
||||
clientURL={clientBranding.clientURL}
|
||||
/>
|
||||
<div className="w-full border-t border-t-border-mid" />
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Field, useToast } from "@probo/ui";
|
||||
@@ -67,12 +73,22 @@ export function MagicLinkForm() {
|
||||
body.set("email", email);
|
||||
body.set("continue", postAuthRedirectUrl);
|
||||
|
||||
const response = await fetch("/api/connect/v1/magic-link/send", {
|
||||
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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { graphql } from "relay-runtime";
|
||||
import type { CompliancePageReferenceListFragment$key } from "#/__generated__/core/CompliancePageReferenceListFragment.graphql";
|
||||
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
||||
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.graphql";
|
||||
import { useUpdateTrustCenterReferenceRankMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
|
||||
import { useUpdateCompliancePageReferenceRankMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||
|
||||
import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
|
||||
|
||||
@@ -65,7 +65,7 @@ export function CompliancePageReferenceList(props: {
|
||||
CompliancePageReferenceListQuery,
|
||||
CompliancePageReferenceListFragment$key
|
||||
>(fragment, fragmentRef);
|
||||
const [updateRank] = useUpdateTrustCenterReferenceRankMutation();
|
||||
const [updateRank] = useUpdateCompliancePageReferenceRankMutation();
|
||||
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"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 cert, ok := cached.(*tls.Certificate); ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err := s.loadFromDatabase(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 {
|
||||
|
||||
@@ -182,8 +182,12 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("email") {
|
||||
if flagEmail == "" {
|
||||
input["email"] = nil
|
||||
} else {
|
||||
input["email"] = flagEmail
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("headquarter-address") {
|
||||
input["headquarterAddress"] = flagHeadquarterAddress
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package management
|
||||
|
||||
@@ -18,16 +24,11 @@ import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// The compliance-page scope string values are part of the external OAuth2
|
||||
// contract and are kept stable even though the feature is named "compliance
|
||||
// portal" on the Go side.
|
||||
const (
|
||||
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
|
||||
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
|
||||
)
|
||||
|
||||
// OAuth2ScopeMappings maps the compliance portal OAuth2 scopes to the actions
|
||||
// they grant.
|
||||
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||
ScopeV1CompliancePortalRead: {
|
||||
ActionCompliancePortalGet,
|
||||
@@ -45,6 +46,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||
ActionComplianceFrameworkList,
|
||||
ActionComplianceCustomLinkList,
|
||||
ActionCustomDomainGet,
|
||||
ActionCompliancePortalCommitmentGroupList,
|
||||
ActionCompliancePortalCommitmentList,
|
||||
},
|
||||
ScopeV1CompliancePortal: {
|
||||
ActionCompliancePortalGet,
|
||||
@@ -62,6 +65,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||
ActionComplianceFrameworkList,
|
||||
ActionComplianceCustomLinkList,
|
||||
ActionCustomDomainGet,
|
||||
ActionCompliancePortalCommitmentGroupList,
|
||||
ActionCompliancePortalCommitmentList,
|
||||
ActionCompliancePortalUpdate,
|
||||
ActionCompliancePortalNonDisclosureAgreementUpload,
|
||||
ActionCompliancePortalNonDisclosureAgreementDelete,
|
||||
@@ -89,5 +94,13 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||
ActionComplianceCustomLinkDelete,
|
||||
ActionCustomDomainCreate,
|
||||
ActionCustomDomainDelete,
|
||||
ActionCompliancePortalCommitmentGroupCreate,
|
||||
ActionCompliancePortalCommitmentGroupUpdate,
|
||||
ActionCompliancePortalCommitmentGroupUpdateRank,
|
||||
ActionCompliancePortalCommitmentGroupDelete,
|
||||
ActionCompliancePortalCommitmentCreate,
|
||||
ActionCompliancePortalCommitmentUpdate,
|
||||
ActionCompliancePortalCommitmentUpdateRank,
|
||||
ActionCompliancePortalCommitmentDelete,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -756,6 +756,11 @@ func (s *OrganizationService) CreateOrganization(
|
||||
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
|
||||
|
||||
defaultDomain := coredata.NewCustomDomain(
|
||||
@@ -777,6 +782,7 @@ func (s *OrganizationService) CreateOrganization(
|
||||
}
|
||||
|
||||
trustCenter.DefaultDomainID = &defaultDomain.ID
|
||||
}
|
||||
|
||||
if err := trustCenter.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert trust center: %w", err)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
if profile.Source == coredata.ProfileSourceManual {
|
||||
if _, err := r.iam.OrganizationService.UpdateUser(ctx, &iam.UpdateUserRequest{
|
||||
profile = nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,7 +45,7 @@ type Query {
|
||||
|
||||
type OAuthClientBranding {
|
||||
name: String!
|
||||
logo: File
|
||||
logoUrl: String
|
||||
clientURL: String
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
|
||||
}
|
||||
|
||||
if branding.LogoURL != nil {
|
||||
result.Logo = &types.File{
|
||||
DownloadURL: *branding.LogoURL,
|
||||
}
|
||||
result.LogoURL = branding.LogoURL
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package server
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user