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 { z } from "zod";
|
||||||
|
|
||||||
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
||||||
import {
|
|
||||||
useCreateTrustCenterReferenceMutation,
|
|
||||||
useUpdateTrustCenterReferenceMutation,
|
|
||||||
} from "#/hooks/graph/TrustCenterReferenceGraph";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
import {
|
||||||
|
useCreateCompliancePageReferenceMutation,
|
||||||
|
useUpdateCompliancePageReferenceMutation,
|
||||||
|
} from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||||
|
|
||||||
const referenceSchema = z.object({
|
const referenceSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
@@ -65,8 +65,8 @@ export const CompliancePageReferenceDialog = forwardRef<CompliancePageReferenceD
|
|||||||
const [editReference, setEditReference] = useState<CompliancePageReferenceListItemFragment$data | null>(null);
|
const [editReference, setEditReference] = useState<CompliancePageReferenceListItemFragment$data | null>(null);
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
|
|
||||||
const [createReference, isCreating] = useCreateTrustCenterReferenceMutation();
|
const [createReference, isCreating] = useCreateCompliancePageReferenceMutation();
|
||||||
const [updateReference, isUpdating] = useUpdateTrustCenterReferenceMutation();
|
const [updateReference, isUpdating] = useUpdateCompliancePageReferenceMutation();
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(
|
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(
|
||||||
referenceSchema,
|
referenceSchema,
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import {
|
|||||||
useDialogRef,
|
useDialogRef,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
|
|
||||||
import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
|
import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
|
||||||
import { deleteTrustCenterReferenceMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
|
|
||||||
import { useMutation } from "#/lib/relay/useMutation";
|
import { useMutation } from "#/lib/relay/useMutation";
|
||||||
|
import { deleteCompliancePageReferenceMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -52,8 +52,8 @@ export function DeleteCompliancePageReferenceDialog({
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
|
|
||||||
const [mutate, isDeleting] = useMutation<TrustCenterReferenceGraphDeleteMutation>(
|
const [mutate, isDeleting] = useMutation<compliancePageReferenceMutationsDeleteMutation>(
|
||||||
deleteTrustCenterReferenceMutation,
|
deleteCompliancePageReferenceMutation,
|
||||||
{
|
{
|
||||||
successMessage: __("Reference deleted successfully"),
|
successMessage: __("Reference deleted successfully"),
|
||||||
errorToast: __("Failed to delete reference"),
|
errorToast: __("Failed to delete reference"),
|
||||||
|
|||||||
@@ -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>.
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
// copyright notice and this permission notice appear in all copies.
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
// The above copyright notice and this permission notice shall be included in
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
// all copies or substantial portions of the Software.
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
//
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button } from "@probo/ui";
|
import { Button } from "@probo/ui";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
export default function MagicLinkAlreadyUsedPage() {
|
export default function MagicLinkAlreadyUsedPage() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
usePageTitle(__("Link Already Used"));
|
usePageTitle(__("Link Already Used"));
|
||||||
|
|
||||||
@@ -33,10 +37,7 @@ export default function MagicLinkAlreadyUsedPage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button className="w-full h-10" to="/auth/login">
|
||||||
className="w-full h-10"
|
|
||||||
onClick={() => void navigate("/auth/login")}
|
|
||||||
>
|
|
||||||
{__("Sign in")}
|
{__("Sign in")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
// copyright notice and this permission notice appear in all copies.
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
// The above copyright notice and this permission notice shall be included in
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
// all copies or substantial portions of the Software.
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
//
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button } from "@probo/ui";
|
import { Button } from "@probo/ui";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
export default function MagicLinkExpiredPage() {
|
export default function MagicLinkExpiredPage() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
usePageTitle(__("Link Expired"));
|
usePageTitle(__("Link Expired"));
|
||||||
|
|
||||||
@@ -33,10 +37,7 @@ export default function MagicLinkExpiredPage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button className="w-full h-10" to="/auth/login">
|
||||||
className="w-full h-10"
|
|
||||||
onClick={() => void navigate("/auth/login")}
|
|
||||||
>
|
|
||||||
{__("Sign in")}
|
{__("Sign in")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ export const signInPageQuery = graphql`
|
|||||||
oauthClientBranding(clientId: $clientId) {
|
oauthClientBranding(clientId: $clientId) {
|
||||||
name
|
name
|
||||||
clientURL
|
clientURL
|
||||||
logo {
|
logoUrl
|
||||||
downloadUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -85,7 +83,7 @@ export default function SignInPage(props: Props) {
|
|||||||
<>
|
<>
|
||||||
<OAuthClientBrandingSection
|
<OAuthClientBrandingSection
|
||||||
name={clientBranding.name}
|
name={clientBranding.name}
|
||||||
logoDownloadUrl={clientBranding.logo?.downloadUrl}
|
logoDownloadUrl={clientBranding.logoUrl}
|
||||||
clientURL={clientBranding.clientURL}
|
clientURL={clientBranding.clientURL}
|
||||||
/>
|
/>
|
||||||
<div className="w-full border-t border-t-border-mid" />
|
<div className="w-full border-t border-t-border-mid" />
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
// copyright notice and this permission notice appear in all copies.
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
// The above copyright notice and this permission notice shall be included in
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
// all copies or substantial portions of the Software.
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
//
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, Field, useToast } from "@probo/ui";
|
import { Button, Field, useToast } from "@probo/ui";
|
||||||
@@ -67,12 +73,22 @@ export function MagicLinkForm() {
|
|||||||
body.set("email", email);
|
body.set("email", email);
|
||||||
body.set("continue", postAuthRedirectUrl);
|
body.set("continue", postAuthRedirectUrl);
|
||||||
|
|
||||||
const response = await fetch("/api/connect/v1/magic-link/send", {
|
let response: Response;
|
||||||
method: "POST",
|
try {
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
response = await fetch("/api/connect/v1/magic-link/send", {
|
||||||
credentials: "include",
|
method: "POST",
|
||||||
body,
|
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) {
|
if (!response.ok) {
|
||||||
toast({
|
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 { CompliancePageReferenceListFragment$key } from "#/__generated__/core/CompliancePageReferenceListFragment.graphql";
|
||||||
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
|
||||||
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.graphql";
|
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.graphql";
|
||||||
import { useUpdateTrustCenterReferenceRankMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
|
import { useUpdateCompliancePageReferenceRankMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
|
||||||
|
|
||||||
import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
|
import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export function CompliancePageReferenceList(props: {
|
|||||||
CompliancePageReferenceListQuery,
|
CompliancePageReferenceListQuery,
|
||||||
CompliancePageReferenceListFragment$key
|
CompliancePageReferenceListFragment$key
|
||||||
>(fragment, fragmentRef);
|
>(fragment, fragmentRef);
|
||||||
const [updateRank] = useUpdateTrustCenterReferenceRankMutation();
|
const [updateRank] = useUpdateCompliancePageReferenceRankMutation();
|
||||||
|
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export const currentTrustDocumentsQuery = graphql`
|
|||||||
query TrustGraphCurrentDocumentsQuery {
|
query TrustGraphCurrentDocumentsQuery {
|
||||||
currentTrustCenter {
|
currentTrustCenter {
|
||||||
id
|
id
|
||||||
title
|
|
||||||
documents(first: 50) {
|
documents(first: 50) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -121,7 +120,6 @@ export const currentTrustSubprocessorsQuery = graphql`
|
|||||||
query TrustGraphCurrentSubprocessorsQuery {
|
query TrustGraphCurrentSubprocessorsQuery {
|
||||||
currentTrustCenter {
|
currentTrustCenter {
|
||||||
id
|
id
|
||||||
title
|
|
||||||
subprocessors(first: 50) {
|
subprocessors(first: 50) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ import (
|
|||||||
|
|
||||||
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
|
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
|
||||||
// HTTPS listener started by the e2e probod (see generateConfig). Compliance
|
// HTTPS listener started by the e2e probod (see generateConfig). Compliance
|
||||||
// pages are served here exclusively, routed by TLS SNI / Host header.
|
// pages are served here exclusively, routed by TLS SNI / Host header. Uses a
|
||||||
const trustCenterHTTPSAddr = "127.0.0.1:443"
|
// non-privileged port so the e2e suite doesn't require root/CAP_NET_BIND_SERVICE.
|
||||||
|
const trustCenterHTTPSAddr = "127.0.0.1:8443"
|
||||||
|
|
||||||
type GraphQLRequest struct {
|
type GraphQLRequest struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func generateConfig() (string, error) {
|
|||||||
// yields {slug}.probopage.localhost subdomains for pages without a
|
// yields {slug}.probopage.localhost subdomains for pages without a
|
||||||
// customer custom domain.
|
// customer custom domain.
|
||||||
"PROBOD_TRUST_CENTER_HTTP_ADDR": ":10080",
|
"PROBOD_TRUST_CENTER_HTTP_ADDR": ":10080",
|
||||||
"PROBOD_TRUST_CENTER_HTTPS_ADDR": ":443",
|
"PROBOD_TRUST_CENTER_HTTPS_ADDR": ":8443",
|
||||||
"PROBOD_TRUST_CENTER_BASE_DOMAIN": "probopage.localhost",
|
"PROBOD_TRUST_CENTER_BASE_DOMAIN": "probopage.localhost",
|
||||||
|
|
||||||
// Keep certificate provisioning snappy so trust-center e2e flows do not
|
// Keep certificate provisioning snappy so trust-center e2e flows do not
|
||||||
|
|||||||
@@ -59,8 +59,14 @@ func (w *CacheStore) WarmCache(ctx context.Context) error {
|
|||||||
err := w.pg.WithConn(
|
err := w.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
var domains coredata.CustomDomains
|
||||||
|
keepCertificateIDs, err := domains.LoadReferencedCertificateIDs(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot load referenced certificate ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var caches coredata.CachedCertificates
|
var caches coredata.CachedCertificates
|
||||||
if err := caches.DeleteUnreferenced(ctx, conn); err != nil {
|
if err := caches.DeleteWhereCertificateIDNotIn(ctx, conn, keepCertificateIDs); err != nil {
|
||||||
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
|
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,19 +122,6 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
|
|||||||
return fmt.Errorf("cannot parse certificate: %w", err)
|
return fmt.Errorf("cannot parse certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loadedCertificate.SSLCertificatePEM) == 0 {
|
|
||||||
return fmt.Errorf("certificate has no certificate PEM")
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyPEM, err := loadedCertificate.DecryptPrivateKey(w.encryptionKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(privateKeyPEM) == 0 {
|
|
||||||
return fmt.Errorf("certificate has no private key PEM")
|
|
||||||
}
|
|
||||||
|
|
||||||
if loadedCertificate.SSLExpiresAt == nil {
|
if loadedCertificate.SSLExpiresAt == nil {
|
||||||
return fmt.Errorf("certificate has no expiry date")
|
return fmt.Errorf("certificate has no expiry date")
|
||||||
}
|
}
|
||||||
@@ -137,18 +130,9 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
|
|||||||
return fmt.Errorf("certificate has expired")
|
return fmt.Errorf("certificate has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &coredata.CachedCertificate{
|
var cache coredata.CachedCertificate
|
||||||
Domain: loadedCertificate.Hostname,
|
if err := cache.RefreshFromCertificate(ctx, conn, &loadedCertificate, w.encryptionKey); err != nil {
|
||||||
CertificatePEM: string(loadedCertificate.SSLCertificatePEM),
|
return fmt.Errorf("cannot refresh certificate cache: %w", err)
|
||||||
PrivateKeyPEM: string(privateKeyPEM),
|
|
||||||
CertificateChain: loadedCertificate.SSLCertificateChain,
|
|
||||||
ExpiresAt: *loadedCertificate.SSLExpiresAt,
|
|
||||||
CachedAt: time.Now(),
|
|
||||||
CertificateID: loadedCertificate.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cache.Upsert(ctx, conn); err != nil {
|
|
||||||
return fmt.Errorf("cannot upsert cache entry: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ func (h *renewHandler) Process(ctx context.Context, certificate coredata.Certifi
|
|||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
fullCertificate := &coredata.Certificate{}
|
fullCertificate := &coredata.Certificate{}
|
||||||
if err := fullCertificate.LoadByIDForUpdateSkipLocked(ctx, tx, coredata.NewNoScope(), certificate.ID); err != nil {
|
if err := fullCertificate.LoadByIDForUpdateSkipLocked(ctx, tx, coredata.NewNoScope(), certificate.ID); err != nil {
|
||||||
|
// Another provision/renewal cycle may already hold the row
|
||||||
|
// (SKIP LOCKED) or the certificate may have been deleted.
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("cannot load certificate for renewal: %w", err)
|
return fmt.Errorf("cannot load certificate for renewal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
@@ -68,7 +67,14 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
|
|||||||
|
|
||||||
if cached, ok := s.cache.Load(domain); ok {
|
if cached, ok := s.cache.Load(domain); ok {
|
||||||
if cert, ok := cached.(*tls.Certificate); ok {
|
if cert, ok := cached.(*tls.Certificate); ok {
|
||||||
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
|
return cert, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRoutable reports whether domain is still a routable custom domain
|
||||||
|
// with an active certificate. It is used to revalidate memory-cache hits so
|
||||||
|
// certificates for deleted or de-provisioned domains stop being served
|
||||||
|
// without waiting for process restart.
|
||||||
|
func (s *Selector) checkRoutable(domain string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
return s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
return requireRoutableDomain(ctx, conn, domain)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) {
|
func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -153,32 +174,20 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Querier, domai
|
|||||||
return fmt.Errorf("certificate has no encrypted private key data")
|
return fmt.Errorf("certificate has no encrypted private key data")
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKeyPEM, err := certificate.DecryptPrivateKey(s.encryptionKey)
|
if certificate.SSLExpiresAt == nil {
|
||||||
if err != nil {
|
return fmt.Errorf("certificate has no expiry")
|
||||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cache.Store(domain, certificate.SSLCertificate)
|
s.cache.Store(domain, certificate.SSLCertificate)
|
||||||
|
|
||||||
cache := &coredata.CachedCertificate{
|
var cache coredata.CachedCertificate
|
||||||
Domain: certificate.Hostname,
|
if err := cache.RefreshFromCertificate(ctx, conn, &certificate, s.encryptionKey); err != nil {
|
||||||
CertificatePEM: string(certificate.SSLCertificatePEM),
|
|
||||||
PrivateKeyPEM: string(privateKeyPEM),
|
|
||||||
CertificateChain: certificate.SSLCertificateChain,
|
|
||||||
ExpiresAt: *certificate.SSLExpiresAt,
|
|
||||||
CachedAt: time.Now(),
|
|
||||||
CertificateID: certificate.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cache.Upsert(ctx, conn); err != nil {
|
|
||||||
return fmt.Errorf("cannot insert cache entry: %w", err)
|
return fmt.Errorf("cannot insert cache entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireRoutableDomain ensures the SNI hostname still maps to a custom domain
|
|
||||||
// row. Orphaned certificates left after domain deletion must not be served.
|
|
||||||
func requireRoutableDomain(ctx context.Context, conn pg.Querier, domain string) error {
|
func requireRoutableDomain(ctx context.Context, conn pg.Querier, domain string) error {
|
||||||
var customDomain coredata.CustomDomain
|
var customDomain coredata.CustomDomain
|
||||||
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {
|
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {
|
||||||
|
|||||||
@@ -182,7 +182,11 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flags().Changed("email") {
|
if cmd.Flags().Changed("email") {
|
||||||
input["email"] = flagEmail
|
if flagEmail == "" {
|
||||||
|
input["email"] = nil
|
||||||
|
} else {
|
||||||
|
input["email"] = flagEmail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flags().Changed("headquarter-address") {
|
if cmd.Flags().Changed("headquarter-address") {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *Service) PublicURLForCompliancePage(
|
|||||||
switch {
|
switch {
|
||||||
case compliancePage.CustomDomainID != nil && byID[*compliancePage.CustomDomainID] != nil && active[*compliancePage.CustomDomainID]:
|
case compliancePage.CustomDomainID != nil && byID[*compliancePage.CustomDomainID] != nil && active[*compliancePage.CustomDomainID]:
|
||||||
host = byID[*compliancePage.CustomDomainID].Domain
|
host = byID[*compliancePage.CustomDomainID].Domain
|
||||||
case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil:
|
case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil && active[*compliancePage.DefaultDomainID]:
|
||||||
host = byID[*compliancePage.DefaultDomainID].Domain
|
host = byID[*compliancePage.DefaultDomainID].Domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
// copyright notice and this permission notice appear in all copies.
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
// The above copyright notice and this permission notice shall be included in
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
// all copies or substantial portions of the Software.
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
//
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
package management
|
package management
|
||||||
|
|
||||||
@@ -18,16 +24,11 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The compliance-page scope string values are part of the external OAuth2
|
|
||||||
// contract and are kept stable even though the feature is named "compliance
|
|
||||||
// portal" on the Go side.
|
|
||||||
const (
|
const (
|
||||||
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
|
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
|
||||||
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
|
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuth2ScopeMappings maps the compliance portal OAuth2 scopes to the actions
|
|
||||||
// they grant.
|
|
||||||
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||||
ScopeV1CompliancePortalRead: {
|
ScopeV1CompliancePortalRead: {
|
||||||
ActionCompliancePortalGet,
|
ActionCompliancePortalGet,
|
||||||
@@ -45,6 +46,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
|||||||
ActionComplianceFrameworkList,
|
ActionComplianceFrameworkList,
|
||||||
ActionComplianceCustomLinkList,
|
ActionComplianceCustomLinkList,
|
||||||
ActionCustomDomainGet,
|
ActionCustomDomainGet,
|
||||||
|
ActionCompliancePortalCommitmentGroupList,
|
||||||
|
ActionCompliancePortalCommitmentList,
|
||||||
},
|
},
|
||||||
ScopeV1CompliancePortal: {
|
ScopeV1CompliancePortal: {
|
||||||
ActionCompliancePortalGet,
|
ActionCompliancePortalGet,
|
||||||
@@ -62,6 +65,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
|||||||
ActionComplianceFrameworkList,
|
ActionComplianceFrameworkList,
|
||||||
ActionComplianceCustomLinkList,
|
ActionComplianceCustomLinkList,
|
||||||
ActionCustomDomainGet,
|
ActionCustomDomainGet,
|
||||||
|
ActionCompliancePortalCommitmentGroupList,
|
||||||
|
ActionCompliancePortalCommitmentList,
|
||||||
ActionCompliancePortalUpdate,
|
ActionCompliancePortalUpdate,
|
||||||
ActionCompliancePortalNonDisclosureAgreementUpload,
|
ActionCompliancePortalNonDisclosureAgreementUpload,
|
||||||
ActionCompliancePortalNonDisclosureAgreementDelete,
|
ActionCompliancePortalNonDisclosureAgreementDelete,
|
||||||
@@ -89,5 +94,13 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
|||||||
ActionComplianceCustomLinkDelete,
|
ActionComplianceCustomLinkDelete,
|
||||||
ActionCustomDomainCreate,
|
ActionCustomDomainCreate,
|
||||||
ActionCustomDomainDelete,
|
ActionCustomDomainDelete,
|
||||||
|
ActionCompliancePortalCommitmentGroupCreate,
|
||||||
|
ActionCompliancePortalCommitmentGroupUpdate,
|
||||||
|
ActionCompliancePortalCommitmentGroupUpdateRank,
|
||||||
|
ActionCompliancePortalCommitmentGroupDelete,
|
||||||
|
ActionCompliancePortalCommitmentCreate,
|
||||||
|
ActionCompliancePortalCommitmentUpdate,
|
||||||
|
ActionCompliancePortalCommitmentUpdateRank,
|
||||||
|
ActionCompliancePortalCommitmentDelete,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ var ViewerPolicy = policy.NewPolicy(
|
|||||||
ActionCompliancePortalReferenceList, ActionCompliancePortalReferenceGetLogoUrl,
|
ActionCompliancePortalReferenceList, ActionCompliancePortalReferenceGetLogoUrl,
|
||||||
ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList,
|
ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList,
|
||||||
ActionComplianceFrameworkList,
|
ActionComplianceFrameworkList,
|
||||||
|
ActionComplianceCustomLinkList,
|
||||||
).WithSID("compliance-portal-read-access").When(organizationCondition),
|
).WithSID("compliance-portal-read-access").When(organizationCondition),
|
||||||
).WithDescription("Read-only compliance portal access for organization viewers")
|
).WithDescription("Read-only compliance portal access for organization viewers")
|
||||||
|
|
||||||
|
|||||||
@@ -14,33 +14,15 @@
|
|||||||
|
|
||||||
package visitor
|
package visitor
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BrandLogoPath = "/brand/logo"
|
BrandLogoPath = "/brand/logo"
|
||||||
BrandDarkLogoPath = "/brand/dark-logo"
|
BrandDarkLogoPath = "/brand/dark-logo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BrandLogoURL(portalBaseURL string) (string, error) {
|
func BrandLogoURL(portalBaseURL string) (string, error) {
|
||||||
return brandAssetURL(portalBaseURL, BrandLogoPath)
|
return portalEndpointURL(portalBaseURL, BrandLogoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BrandDarkLogoURL(portalBaseURL string) (string, error) {
|
func BrandDarkLogoURL(portalBaseURL string) (string, error) {
|
||||||
return brandAssetURL(portalBaseURL, BrandDarkLogoPath)
|
return portalEndpointURL(portalBaseURL, BrandDarkLogoPath)
|
||||||
}
|
|
||||||
|
|
||||||
func brandAssetURL(portalBaseURL string, path string) (string, error) {
|
|
||||||
parsed, err := url.Parse(portalBaseURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("cannot parse portal base URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.Path = path
|
|
||||||
parsed.RawQuery = ""
|
|
||||||
parsed.Fragment = ""
|
|
||||||
|
|
||||||
return parsed.String(), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,38 +29,26 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CIMDClientIDURL(portalBaseURL string) (string, error) {
|
func CIMDClientIDURL(portalBaseURL string) (string, error) {
|
||||||
parsed, err := url.Parse(portalBaseURL)
|
return portalEndpointURL(portalBaseURL, CIMDMetadataPath)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.Path = CIMDMetadataPath
|
|
||||||
parsed.RawQuery = ""
|
|
||||||
parsed.Fragment = ""
|
|
||||||
|
|
||||||
return parsed.String(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func OAuthCallbackURL(portalBaseURL string) (string, error) {
|
func OAuthCallbackURL(portalBaseURL string) (string, error) {
|
||||||
parsed, err := url.Parse(portalBaseURL)
|
return portalEndpointURL(portalBaseURL, OAuthCallbackPath)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.Path = OAuthCallbackPath
|
|
||||||
parsed.RawQuery = ""
|
|
||||||
parsed.Fragment = ""
|
|
||||||
|
|
||||||
return parsed.String(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func PortalRootURL(rawURL string) (string, error) {
|
func PortalRootURL(rawURL string) (string, error) {
|
||||||
parsed, err := url.Parse(rawURL)
|
return portalEndpointURL(rawURL, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// portalEndpointURL replaces the path on a portal base URL and clears
|
||||||
|
// query/fragment. Shared by CIMD, OAuth callback, and brand asset URLs.
|
||||||
|
func portalEndpointURL(portalBaseURL string, path string) (string, error) {
|
||||||
|
parsed, err := url.Parse(portalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot parse portal URL: %w", err)
|
return "", fmt.Errorf("cannot parse portal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed.Path = ""
|
parsed.Path = path
|
||||||
parsed.RawQuery = ""
|
parsed.RawQuery = ""
|
||||||
parsed.Fragment = ""
|
parsed.Fragment = ""
|
||||||
|
|
||||||
|
|||||||
@@ -55,28 +55,3 @@ func (s *Service) ListCommitmentGroupsForPortalID(
|
|||||||
|
|
||||||
return page.NewPage(groups, cursor), nil
|
return page.NewPage(groups, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetCommitmentGroup(
|
|
||||||
ctx context.Context,
|
|
||||||
scope coredata.Scoper,
|
|
||||||
groupID gid.GID,
|
|
||||||
) (*coredata.CompliancePortalCommitmentGroup, error) {
|
|
||||||
group := &coredata.CompliancePortalCommitmentGroup{}
|
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
|
||||||
ctx,
|
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
|
||||||
err := group.LoadByID(ctx, conn, scope, groupID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot load compliance portal commitment group: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return group, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
@@ -153,6 +154,38 @@ func (s *Service) GetPortalEffectiveCanonicalHost(ctx context.Context, complianc
|
|||||||
return host, nil
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPortalCanonicalBaseURL rewrites currentBaseURL to the compliance page's
|
||||||
|
// canonical host, if one is set. OAuth client_id and redirect_uri values must
|
||||||
|
// always be derived from the canonical base URL: the SNI middleware only
|
||||||
|
// redirects secondary domains to the canonical host for non-well-known
|
||||||
|
// paths, so a client_id fetched from /.well-known/oauth-client-metadata on a
|
||||||
|
// secondary domain must already advertise the canonical redirect_uri to stay
|
||||||
|
// consistent with what /callback uses at token exchange time. When no
|
||||||
|
// canonical host can be determined, currentBaseURL is returned unchanged.
|
||||||
|
func (s *Service) GetPortalCanonicalBaseURL(
|
||||||
|
ctx context.Context,
|
||||||
|
compliancePageID gid.GID,
|
||||||
|
currentBaseURL string,
|
||||||
|
) (string, error) {
|
||||||
|
canonicalHost, err := s.GetPortalEffectiveCanonicalHost(ctx, compliancePageID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot resolve canonical host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canonicalHost == "" {
|
||||||
|
return currentBaseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(currentBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot parse portal base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.Host = canonicalHost
|
||||||
|
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
|
func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
|
||||||
compliancePage := &coredata.TrustCenter{}
|
compliancePage := &coredata.TrustCenter{}
|
||||||
|
|
||||||
|
|||||||
@@ -170,22 +170,25 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUnreferenced removes cache entries whose certificate is no longer
|
// DeleteWhereCertificateIDNotIn removes cache rows whose certificate is not
|
||||||
// referenced by any custom domain, so deleted domains cannot keep a usable
|
// among the provided IDs. An empty keep set deletes every cache row.
|
||||||
// TLS cache entry.
|
func (cc *CachedCertificates) DeleteWhereCertificateIDNotIn(
|
||||||
func (cc *CachedCertificates) DeleteUnreferenced(ctx context.Context, conn pg.Querier) error {
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
keepCertificateIDs []gid.GID,
|
||||||
|
) error {
|
||||||
q := `
|
q := `
|
||||||
DELETE FROM
|
DELETE FROM
|
||||||
cached_certificates
|
cached_certificates
|
||||||
WHERE
|
WHERE
|
||||||
NOT EXISTS (
|
NOT (certificate_id = ANY(@keep_certificate_ids::text[]))
|
||||||
SELECT 1
|
|
||||||
FROM custom_domains
|
|
||||||
WHERE custom_domains.certificate_id = cached_certificates.certificate_id
|
|
||||||
)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, q, pgx.NamedArgs{})
|
_, err := conn.Exec(
|
||||||
|
ctx,
|
||||||
|
q,
|
||||||
|
pgx.NamedArgs{"keep_certificate_ids": keepCertificateIDs},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
|
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,3 +377,32 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadReferencedCertificateIDs returns certificate IDs currently linked from
|
||||||
|
// any custom domain. Used by the certificate cache warmer to drop orphaned
|
||||||
|
// cache rows without joining across entity tables.
|
||||||
|
func (domains *CustomDomains) LoadReferencedCertificateIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
) ([]gid.GID, error) {
|
||||||
|
q := `
|
||||||
|
SELECT DISTINCT
|
||||||
|
certificate_id
|
||||||
|
FROM
|
||||||
|
custom_domains
|
||||||
|
WHERE
|
||||||
|
certificate_id IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, pgx.NamedArgs{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query referenced certificate ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certificateIDs, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot collect referenced certificate ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificateIDs, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ INSERT INTO trust_centers (
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
active,
|
active,
|
||||||
slug,
|
slug,
|
||||||
|
search_engine_indexing,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -41,6 +42,7 @@ SELECT
|
|||||||
'\s+', '-', 'g'
|
'\s+', '-', 'g'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
'NOT_INDEXABLE',
|
||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
FROM organizations o
|
FROM organizations o
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ CREATE TABLE certificates (
|
|||||||
ssl_certificate_chain TEXT,
|
ssl_certificate_chain TEXT,
|
||||||
status custom_domain_ssl_status NOT NULL,
|
status custom_domain_ssl_status NOT NULL,
|
||||||
ssl_expires_at TIMESTAMP WITH TIME ZONE,
|
ssl_expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
ssl_retry_count INTEGER NOT NULL DEFAULT 0,
|
ssl_retry_count INTEGER NOT NULL,
|
||||||
ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
|
ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
|
||||||
http_challenge_token TEXT,
|
http_challenge_token TEXT,
|
||||||
http_challenge_key_auth TEXT,
|
http_challenge_key_auth TEXT,
|
||||||
@@ -100,6 +100,12 @@ SET certificate_id = cd.certificate_id
|
|||||||
FROM custom_domains cd
|
FROM custom_domains cd
|
||||||
WHERE cd.id = cc.custom_domain_id;
|
WHERE cd.id = cc.custom_domain_id;
|
||||||
|
|
||||||
|
-- Entries that could not be repointed (stale custom_domain_id, or a domain
|
||||||
|
-- whose certificate was never migrated) are unusable cache rows; drop them
|
||||||
|
-- rather than leaving certificate_id NULL for callers that always expect it.
|
||||||
|
DELETE FROM cached_certificates WHERE certificate_id IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE cached_certificates ALTER COLUMN certificate_id SET NOT NULL;
|
||||||
ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
|
ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
|
||||||
|
|
||||||
-- Drop the certificate lifecycle columns now living on certificates.
|
-- Drop the certificate lifecycle columns now living on certificates.
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ WITH pending_pages AS (
|
|||||||
FROM trust_centers tc
|
FROM trust_centers tc
|
||||||
WHERE tc.default_domain_id IS NULL
|
WHERE tc.default_domain_id IS NULL
|
||||||
AND NULLIF(current_setting('probo.trust_center_base_domain', true), '') IS NOT NULL
|
AND NULLIF(current_setting('probo.trust_center_base_domain', true), '') IS NOT NULL
|
||||||
|
-- Skip hostnames that already exist: minting a certificate or custom
|
||||||
|
-- domain for them would violate their unique constraints and abort
|
||||||
|
-- the whole migration.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM certificates c
|
||||||
|
WHERE c.hostname = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM custom_domains cd
|
||||||
|
WHERE cd.domain = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
|
||||||
|
)
|
||||||
),
|
),
|
||||||
minted_certificates AS (
|
minted_certificates AS (
|
||||||
INSERT INTO certificates (
|
INSERT INTO certificates (
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
|
|
||||||
UPDATE trust_centers
|
UPDATE trust_centers
|
||||||
SET
|
SET
|
||||||
slug = slug || '-' || encode(gen_random_bytes(4), 'hex'),
|
slug = slug || '-' || encode(gen_random_bytes(16), 'hex'),
|
||||||
updated_at = clock_timestamp();
|
updated_at = clock_timestamp();
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// minRSAModulusBits is the smallest RSA modulus size accepted for RS256
|
||||||
|
// verification. NIST SP 800-131A and industry guidance both treat moduli
|
||||||
|
// below 2048 bits as too weak for continued use.
|
||||||
|
const minRSAModulusBits = 2048
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// JWK represents a JSON Web Key (RFC 7517).
|
// JWK represents a JSON Web Key (RFC 7517).
|
||||||
JWK struct {
|
JWK struct {
|
||||||
@@ -126,8 +131,13 @@ func RSAPublicKeyFromJWK(jwk JWK) (*rsa.PublicKey, error) {
|
|||||||
return nil, fmt.Errorf("cannot convert jwk to rsa public key: invalid rsa exponent")
|
return nil, fmt.Errorf("cannot convert jwk to rsa public key: invalid rsa exponent")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n := new(big.Int).SetBytes(nBytes)
|
||||||
|
if n.BitLen() < minRSAModulusBits {
|
||||||
|
return nil, fmt.Errorf("cannot convert jwk to rsa public key: modulus is %d bits, minimum is %d", n.BitLen(), minRSAModulusBits)
|
||||||
|
}
|
||||||
|
|
||||||
return &rsa.PublicKey{
|
return &rsa.PublicKey{
|
||||||
N: new(big.Int).SetBytes(nBytes),
|
N: n,
|
||||||
E: int(e.Int64()),
|
E: int(e.Int64()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -159,6 +169,15 @@ func VerifyJWT(raw string, pubKey *rsa.PublicKey) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("cannot decode jwt header: %w", err)
|
return nil, fmt.Errorf("cannot decode jwt header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var headerFields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(headerJSON, &headerFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse jwt header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := headerFields["crit"]; ok {
|
||||||
|
return nil, fmt.Errorf("cannot verify jwt: unsupported critical header parameter")
|
||||||
|
}
|
||||||
|
|
||||||
var header JWTHeader
|
var header JWTHeader
|
||||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||||
return nil, fmt.Errorf("cannot parse jwt header: %w", err)
|
return nil, fmt.Errorf("cannot parse jwt header: %w", err)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
)
|
)
|
||||||
@@ -54,7 +55,7 @@ func newTestS3Service(t *testing.T, handler http.HandlerFunc) *filemanager.Servi
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return filemanager.NewService(nil, nil, s3Client)
|
return filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenFile_StreamsBody(t *testing.T) {
|
func TestOpenFile_StreamsBody(t *testing.T) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
)
|
)
|
||||||
@@ -62,7 +63,7 @@ func (s *Service) ServePublicFile(
|
|||||||
|
|
||||||
obj, err := s.OpenFile(ctx, file, conds)
|
obj, err := s.OpenFile(ctx, file, conds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("cannot open public file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
@@ -99,7 +100,12 @@ func (s *Service) ServePublicFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.Copy(w, obj.Body); err != nil {
|
if _, err := io.Copy(w, obj.Body); err != nil {
|
||||||
return err
|
// The response status and headers are already written at this point,
|
||||||
|
// so returning the error would make the caller render a JSON 500
|
||||||
|
// body into an already-started (and possibly partial) response.
|
||||||
|
// Log it and stop instead.
|
||||||
|
s.logger.ErrorCtx(ctx, "cannot stream public file", log.Error(err), log.String("file_id", fileID.String()))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ package filemanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
)
|
)
|
||||||
@@ -30,16 +31,19 @@ type Service struct {
|
|||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
baseURL *baseurl.BaseURL
|
baseURL *baseurl.BaseURL
|
||||||
s3Client *awss3.Client
|
s3Client *awss3.Client
|
||||||
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
baseURL *baseurl.BaseURL,
|
baseURL *baseurl.BaseURL,
|
||||||
s3Client *awss3.Client,
|
s3Client *awss3.Client,
|
||||||
|
logger *log.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ package filemanager_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
@@ -45,7 +47,7 @@ func TestGenerateFileURL_PublicFile(t *testing.T) {
|
|||||||
t.Fatalf("cannot parse base URL: %v", err)
|
t.Fatalf("cannot parse base URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := filemanager.NewService(nil, base, nil)
|
svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
|
||||||
file := &coredata.File{
|
file := &coredata.File{
|
||||||
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
|
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
|
||||||
Visibility: coredata.FileVisibilityPublic,
|
Visibility: coredata.FileVisibilityPublic,
|
||||||
@@ -66,7 +68,7 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) {
|
|||||||
t.Fatalf("cannot parse base URL: %v", err)
|
t.Fatalf("cannot parse base URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := filemanager.NewService(nil, base, nil)
|
svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
|
||||||
file := &coredata.File{
|
file := &coredata.File{
|
||||||
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
|
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
|
||||||
Visibility: coredata.FileVisibilityPrivate,
|
Visibility: coredata.FileVisibilityPrivate,
|
||||||
@@ -88,7 +90,7 @@ func TestGeneratePresignedURL_EscapesContentDispositionFilename(t *testing.T) {
|
|||||||
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
|
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
svc := filemanager.NewService(nil, nil, s3Client)
|
svc := filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
|
||||||
file := &coredata.File{
|
file := &coredata.File{
|
||||||
BucketName: "uploads",
|
BucketName: "uploads",
|
||||||
FileKey: "tenant/file",
|
FileKey: "tenant/file",
|
||||||
|
|||||||
@@ -580,6 +580,19 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
|
|||||||
return fmt.Errorf("cannot generate magic link token: %w", err)
|
return fmt.Errorf("cannot generate magic link token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
senderName := magicLinkDefaultSenderName
|
||||||
|
|
||||||
|
if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
|
||||||
|
branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot load oauth2 client branding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if branding != nil {
|
||||||
|
senderName = branding.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return s.pg.WithTx(
|
return s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
@@ -596,7 +609,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
|
|||||||
|
|
||||||
fullName := req.Email.Username()
|
fullName := req.Email.Username()
|
||||||
identity := &coredata.Identity{}
|
identity := &coredata.Identity{}
|
||||||
senderName := magicLinkDefaultSenderName
|
|
||||||
|
|
||||||
if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil {
|
if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil {
|
||||||
if identity.FullName != "" {
|
if identity.FullName != "" {
|
||||||
@@ -608,17 +620,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
|
|
||||||
branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot load oauth2 client branding: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if branding != nil {
|
|
||||||
senderName = branding.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
|
emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
|
||||||
|
|
||||||
if req.MagicLinkBaseURL != nil {
|
if req.MagicLinkBaseURL != nil {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
@@ -117,25 +116,6 @@ func NewIDTokenClaims(
|
|||||||
return claims
|
return claims
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseIDTokenClaims(raw string) (*IDTokenClaims, error) {
|
|
||||||
parts := strings.Split(raw, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return nil, fmt.Errorf("cannot parse id token: invalid format")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot parse id token payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var claims IDTokenClaims
|
|
||||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot decode id token claims: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseIDTokenIdentity(
|
func ParseIDTokenIdentity(
|
||||||
raw string,
|
raw string,
|
||||||
jwks *jose.JWKS,
|
jwks *jose.JWKS,
|
||||||
|
|||||||
@@ -756,28 +756,34 @@ func (s *OrganizationService) CreateOrganization(
|
|||||||
return fmt.Errorf("cannot insert mailing list: %w", err)
|
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(
|
defaultDomain := coredata.NewCustomDomain(
|
||||||
tenantID,
|
tenantID,
|
||||||
organization.ID,
|
organization.ID,
|
||||||
defaultDomainHostname,
|
defaultDomainHostname,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
|
certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot ensure certificate for default custom domain: %w", err)
|
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 {
|
if err := trustCenter.Insert(ctx, tx, scope); err != nil {
|
||||||
return fmt.Errorf("cannot insert trust center: %w", err)
|
return fmt.Errorf("cannot insert trust center: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ type (
|
|||||||
magicLinkTokenValidity time.Duration
|
magicLinkTokenValidity time.Duration
|
||||||
sessionDuration time.Duration
|
sessionDuration time.Duration
|
||||||
bucket string
|
bucket string
|
||||||
encryptionKey cipher.EncryptionKey
|
|
||||||
trustCenterBaseDomain string
|
trustCenterBaseDomain string
|
||||||
certManager *certmanager.Service
|
certManager *certmanager.Service
|
||||||
certificate *x509.Certificate
|
certificate *x509.Certificate
|
||||||
@@ -150,6 +149,10 @@ func NewService(
|
|||||||
return nil, fmt.Errorf("oauth2 scope registry is required")
|
return nil, fmt.Errorf("oauth2 scope registry is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.CertManager == nil {
|
||||||
|
return nil, fmt.Errorf("cert manager is required")
|
||||||
|
}
|
||||||
|
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
fm: fm,
|
fm: fm,
|
||||||
@@ -163,7 +166,6 @@ func NewService(
|
|||||||
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
|
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
|
||||||
sessionDuration: cfg.SessionDuration,
|
sessionDuration: cfg.SessionDuration,
|
||||||
bucket: cfg.Bucket,
|
bucket: cfg.Bucket,
|
||||||
encryptionKey: cfg.EncryptionKey,
|
|
||||||
trustCenterBaseDomain: cfg.TrustCenterBaseDomain,
|
trustCenterBaseDomain: cfg.TrustCenterBaseDomain,
|
||||||
certManager: cfg.CertManager,
|
certManager: cfg.CertManager,
|
||||||
certificate: cfg.Certificate,
|
certificate: cfg.Certificate,
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ func (impl *Implm) Run(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client)
|
fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client, l)
|
||||||
|
|
||||||
commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService)
|
commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/server/api/complianceportal"
|
"go.probo.inc/probo/pkg/server/api/complianceportal"
|
||||||
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
|
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
|
||||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||||
|
"go.probo.inc/probo/pkg/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateFullName is the resolver for the updateFullName field.
|
// UpdateFullName is the resolver for the updateFullName field.
|
||||||
@@ -25,43 +26,66 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
|
|||||||
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access")
|
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access")
|
||||||
}
|
}
|
||||||
|
|
||||||
identity, err := r.iam.AccountService.UpdateIdentity(
|
|
||||||
ctx,
|
|
||||||
identity.ID,
|
|
||||||
&iam.UpdateIdentityRequest{
|
|
||||||
FullName: input.FullName,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
|
|
||||||
return nil, gqlutils.Internal(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
compliancePage := complianceportal.CompliancePageFromContext(ctx)
|
compliancePage := complianceportal.CompliancePageFromContext(ctx)
|
||||||
|
|
||||||
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
|
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// External trust-center visitors have no organization profile; updating
|
// External trust-center visitors have no organization profile; only
|
||||||
// the identity's full name above is all that is needed for them.
|
// their identity needs updating.
|
||||||
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
|
if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok {
|
||||||
return &types.UpdateFullNamePayload{Success: true}, nil
|
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
|
profile = nil
|
||||||
|
|
||||||
return nil, gqlutils.Internal(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.Source == coredata.ProfileSourceManual {
|
// The identity and profile full names are validated by different rules.
|
||||||
if _, err := r.iam.OrganizationService.UpdateUser(ctx, &iam.UpdateUserRequest{
|
// Validate the profile update up front so it cannot fail after the
|
||||||
|
// identity has already been mutated, keeping the two in sync.
|
||||||
|
var updateUserRequest *iam.UpdateUserRequest
|
||||||
|
if profile != nil && profile.Source == coredata.ProfileSourceManual {
|
||||||
|
updateUserRequest = &iam.UpdateUserRequest{
|
||||||
ID: profile.ID,
|
ID: profile.ID,
|
||||||
FullName: identity.FullName,
|
FullName: input.FullName,
|
||||||
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
|
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
|
||||||
Kind: profile.Kind,
|
Kind: profile.Kind,
|
||||||
Position: profile.Position,
|
Position: profile.Position,
|
||||||
ContractStartDate: &profile.ContractStartDate,
|
ContractStartDate: &profile.ContractStartDate,
|
||||||
ContractEndDate: &profile.ContractEndDate,
|
ContractEndDate: &profile.ContractEndDate,
|
||||||
}); err != nil {
|
}
|
||||||
|
|
||||||
|
if err := updateUserRequest.Validate(); err != nil {
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot validate profile update", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.iam.AccountService.UpdateIdentity(
|
||||||
|
ctx,
|
||||||
|
identity.ID,
|
||||||
|
&iam.UpdateIdentityRequest{
|
||||||
|
FullName: input.FullName,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateUserRequest != nil {
|
||||||
|
if _, err := r.iam.OrganizationService.UpdateUser(ctx, updateUserRequest); err != nil {
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) {
|
|||||||
func(r chi.Router) {
|
func(r chi.Router) {
|
||||||
r.Use(complianceportal.NewCompliancePagePresenceMiddleware())
|
r.Use(complianceportal.NewCompliancePagePresenceMiddleware())
|
||||||
|
|
||||||
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler())
|
r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler(cfg.Visitor))
|
||||||
r.Method(http.MethodGet, complianceportal.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File))
|
r.Method(http.MethodGet, complianceportal.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File))
|
||||||
r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File))
|
r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File))
|
||||||
r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler)
|
r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler)
|
||||||
|
|||||||
@@ -100,7 +100,15 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
|
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, portal.ID, *portalBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
|
||||||
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
|
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
@@ -108,7 +116,7 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
|
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
|
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
|
|||||||
@@ -23,22 +23,32 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/server/api/complianceportal"
|
"go.probo.inc/probo/pkg/server/api/complianceportal"
|
||||||
)
|
)
|
||||||
|
|
||||||
type oauthClientMetadataHandler struct{}
|
type oauthClientMetadataHandler struct {
|
||||||
|
visitor *visitor.Service
|
||||||
|
}
|
||||||
|
|
||||||
func NewOAuthClientMetadataHandler() http.Handler {
|
func NewOAuthClientMetadataHandler(visitorSvc *visitor.Service) http.Handler {
|
||||||
return &oauthClientMetadataHandler{}
|
return &oauthClientMetadataHandler{visitor: visitorSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
compliancePage := complianceportal.CompliancePageFromContext(r.Context())
|
ctx := r.Context()
|
||||||
baseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context())
|
|
||||||
|
compliancePage := complianceportal.CompliancePageFromContext(ctx)
|
||||||
|
baseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
|
||||||
|
|
||||||
if compliancePage == nil || baseURL == nil {
|
if compliancePage == nil || baseURL == nil {
|
||||||
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
|
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := visitor.BuildClientMetadataDocument(compliancePage, *baseURL)
|
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *baseURL)
|
||||||
|
if err != nil {
|
||||||
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := visitor.BuildClientMetadataDocument(compliancePage, canonicalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ func NewOAuthInitiateHandler(
|
|||||||
func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
compliancePage := complianceportal.CompliancePageFromContext(ctx)
|
||||||
portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
|
portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
|
||||||
if portalBaseURL == nil {
|
if compliancePage == nil || portalBaseURL == nil {
|
||||||
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
|
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,15 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
|
canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *portalBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
|
||||||
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
|
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
@@ -83,7 +92,7 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
|
redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
|
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type Query {
|
|||||||
|
|
||||||
type OAuthClientBranding {
|
type OAuthClientBranding {
|
||||||
name: String!
|
name: String!
|
||||||
logo: File
|
logoUrl: String
|
||||||
clientURL: String
|
clientURL: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/complianceportal/visitor"
|
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
@@ -39,7 +38,6 @@ import (
|
|||||||
|
|
||||||
func NewGraphQLHandler(
|
func NewGraphQLHandler(
|
||||||
svc *iam.Service,
|
svc *iam.Service,
|
||||||
trustSvc *visitor.Service,
|
|
||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
fileManagerSvc *filemanager.Service,
|
fileManagerSvc *filemanager.Service,
|
||||||
baseURL *baseurl.BaseURL,
|
baseURL *baseurl.BaseURL,
|
||||||
@@ -52,7 +50,6 @@ func NewGraphQLHandler(
|
|||||||
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
|
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
iam: svc,
|
iam: svc,
|
||||||
trust: trustSvc,
|
|
||||||
scopeRegistry: svc.OAuth2ScopeRegistry,
|
scopeRegistry: svc.OAuth2ScopeRegistry,
|
||||||
fileManager: fileManagerSvc,
|
fileManager: fileManagerSvc,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if branding.LogoURL != nil {
|
if branding.LogoURL != nil {
|
||||||
result.Logo = &types.File{
|
result.LogoURL = branding.LogoURL
|
||||||
DownloadURL: *branding.LogoURL,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ type (
|
|||||||
batchAuthorize authz.BatchAuthorizeFunc
|
batchAuthorize authz.BatchAuthorizeFunc
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
trust *visitor.Service
|
|
||||||
scopeRegistry *oauth2scope.Registry
|
scopeRegistry *oauth2scope.Registry
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
baseURL *baseurl.BaseURL
|
baseURL *baseurl.BaseURL
|
||||||
@@ -93,7 +92,7 @@ func NewMux(
|
|||||||
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
|
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
|
||||||
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
|
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
|
||||||
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
|
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
|
||||||
graphqlHandler := NewGraphQLHandler(svc, trustSvc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
|
graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
|
||||||
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
|
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
|
||||||
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))
|
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
// copyright notice and this permission notice appear in all copies.
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
//
|
//
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
// The above copyright notice and this permission notice shall be included in
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
// all copies or substantial portions of the Software.
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
//
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ func Make(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func MakeWithEntropy(s string) string {
|
func MakeWithEntropy(s string) string {
|
||||||
|
const maxDNSLabel = 63
|
||||||
|
|
||||||
base := Make(s)
|
base := Make(s)
|
||||||
suffix := rand.MustHexString(4)
|
suffix := rand.MustHexString(4)
|
||||||
|
|
||||||
@@ -53,5 +55,18 @@ func MakeWithEntropy(s string) string {
|
|||||||
return suffix
|
return suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNS labels are capped at 63 octets. Keep the entropy suffix and
|
||||||
|
// truncate the name-derived prefix so hostnames stay provisionable.
|
||||||
|
maxBase := maxDNSLabel - 1 - len(suffix)
|
||||||
|
if maxBase < 1 {
|
||||||
|
return suffix
|
||||||
|
}
|
||||||
|
if len(base) > maxBase {
|
||||||
|
base = strings.Trim(base[:maxBase], "-")
|
||||||
|
if base == "" {
|
||||||
|
return suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return base + "-" + suffix
|
return base + "-" + suffix
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
package slug
|
package slug
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -91,4 +92,15 @@ func TestMakeWithEntropy(t *testing.T) {
|
|||||||
assert.NotEqual(t, first, second, "MakeWithEntropy should produce distinct slugs")
|
assert.NotEqual(t, first, second, "MakeWithEntropy should produce distinct slugs")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"long names stay within dns label length",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := MakeWithEntropy(strings.Repeat("Very Long Organization Name ", 10))
|
||||||
|
assert.LessOrEqual(t, len(got), 63)
|
||||||
|
assert.Regexp(t, `^[a-z0-9-]+-[0-9a-f]{8}$`, got)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user