Rewrite identity and access management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-03 19:23:15 +01:00
parent 4ed3f5a067
commit 74fc3b8cd1
201 changed files with 32895 additions and 23649 deletions

2
.gitattributes vendored
View File

@@ -1 +1 @@
pkg/server/api/console/v1/v1_resolver.go linguist-generated=false
pkg/server/api/console/v1/v1_resolver.go linguist-detectable

View File

@@ -18,6 +18,7 @@
"@probo/hooks": "1.0.0",
"@probo/i18n": "1.0.0",
"@probo/react-lazy": "1.0.0",
"@probo/relay": "^1.0.0",
"@probo/routes": "^1.0.0",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",

View File

@@ -2,7 +2,7 @@ import { useLocation, useRouteError } from "react-router";
import { IconPageCross } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useEffect, useRef } from "react";
import { AuthenticationRequiredError } from "/providers/RelayProviders";
import { AuthenticationRequiredError } from "@probo/relay";
const classNames = {
wrapper: "py-10 text-center space-y-2 ",

View File

@@ -0,0 +1,18 @@
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { makeFetchQuery } from "@probo/relay";
const source = new RecordSource();
const store = new Store(source, {
queryCacheExpirationTime: 1 * 60 * 1000,
gcReleaseBufferSize: 20,
});
export const consoleEnvironment = new Environment({
network: Network.create(makeFetchQuery("/api/console/v1/query")),
store,
});
export const connectEnvironment = new Environment({
network: Network.create(makeFetchQuery("/api/connect/v1/query")),
store,
});

View File

@@ -32,7 +32,6 @@ import type { EmployeeLayoutQuery as EmployeeLayoutQueryType } from "./__generat
import { Suspense, useState, useEffect, useMemo, use } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { PageError } from "/components/PageError";
import { buildEndpoint } from "/providers/RelayProviders";
import { PermissionsProvider } from "/providers/PermissionsProvider";
import { PermissionsContext } from "/providers/PermissionsContext";
@@ -71,11 +70,7 @@ export function EmployeeLayout() {
);
}
function EmployeeLayoutContent({
organizationId,
}: {
organizationId: string;
}) {
function EmployeeLayoutContent({ organizationId }: { organizationId: string }) {
const data = useLazyLoadQuery<EmployeeLayoutQueryType>(EmployeeLayoutQuery, {
organizationId,
});
@@ -352,7 +347,7 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
) => {
e.preventDefault();
fetch(buildEndpoint("/connect/logout"), {
fetch("/connect/logout", {
method: "DELETE",
headers: {
"Content-Type": "application/json",

View File

@@ -50,7 +50,6 @@ import { graphql } from "relay-runtime";
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
import { PageError } from "/components/PageError";
import { PermissionsProvider } from "/providers/PermissionsProvider";
import { buildEndpoint } from "/providers/RelayProviders";
import { PermissionsContext } from "/providers/PermissionsContext";
const MainLayoutQuery = graphql`
@@ -283,7 +282,7 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
) => {
e.preventDefault();
fetch(buildEndpoint("/connect/logout"), {
fetch("/connect/logout", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
@@ -371,7 +370,6 @@ interface InvitationsResponse {
invitations: Invitation[];
}
function OrganizationSelector({
currentOrganization,
}: {

View File

@@ -1,7 +1,6 @@
import { Outlet } from "react-router";
import { Logo, Button, IconArrowBoxLeft, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { buildEndpoint } from "/providers/RelayProviders";
import type { ReactNode } from "react";
type Props = {
@@ -11,18 +10,23 @@ type Props = {
isAuthenticated?: boolean;
};
export function PublicTrustCenterLayout({ organizationName, organizationLogo, children, isAuthenticated }: Props) {
export function PublicTrustCenterLayout({
organizationName,
organizationLogo,
children,
isAuthenticated,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const handleLogout = async () => {
const handleLogout = async () => {
try {
const response = await fetch(buildEndpoint('/api/trust/v1/auth/logout'), {
method: 'DELETE',
const response = await fetch("/api/trust/v1/auth/logout", {
method: "DELETE",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
credentials: 'include',
credentials: "include",
});
if (!response.ok) {

View File

@@ -1,7 +1,6 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./App";
import { RelayProvider } from "./providers/RelayProviders";
import { TranslatorProvider } from "./providers/TranslatorProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -15,10 +14,8 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<RelayProvider>
<TranslatorProvider>
<App />
</TranslatorProvider>
</RelayProvider>
</QueryClientProvider>,
<TranslatorProvider>
<App />
</TranslatorProvider>
</QueryClientProvider>
);

View File

@@ -31,7 +31,7 @@ import { formatDate } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { Controller } from "react-hook-form";
import { z } from "zod";
import { UnAuthenticatedError } from "/providers/RelayProviders";
import { UnAuthenticatedError } from "@probo/relay";
interface APIKey {
id: string;
@@ -56,10 +56,14 @@ interface Organization {
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresIn: z.enum(["1month", "3months", "6months", "1year"]),
organizations: z.array(z.object({
organizationId: z.string(),
role: z.string(),
})).min(1, "At least one organization is required"),
organizations: z
.array(
z.object({
organizationId: z.string(),
role: z.string(),
})
)
.min(1, "At least one organization is required"),
});
type CreateFormData = z.infer<typeof createSchema>;
@@ -79,19 +83,24 @@ export default function APIKeysPage() {
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedOrganizations, setSelectedOrganizations] = useState<string[]>([]);
const [organizationRoles, setOrganizationRoles] = useState<Record<string, string>>({});
const [selectedOrganizations, setSelectedOrganizations] = useState<string[]>(
[]
);
const [organizationRoles, setOrganizationRoles] = useState<
Record<string, string>
>({});
const [editingAPIKey, setEditingAPIKey] = useState<APIKey | null>(null);
const [editingName, setEditingName] = useState<string>("");
const [error, setError] = useState<Error | null>(null);
const { formState, handleSubmit, reset, control, setValue, register } = useFormWithSchema(createSchema, {
defaultValues: {
name: new Date().toISOString().split('T')[0],
expiresIn: "1month",
organizations: [],
},
});
const { formState, handleSubmit, reset, control, setValue, register } =
useFormWithSchema(createSchema, {
defaultValues: {
name: new Date().toISOString().split("T")[0],
expiresIn: "1month",
organizations: [],
},
});
if (error) {
throw error;
@@ -99,14 +108,16 @@ export default function APIKeysPage() {
const fetchAPIKeys = async () => {
try {
const response = await fetch('/connect/api-keys', { credentials: 'include' });
const response = await fetch("/connect/api-keys", {
credentials: "include",
});
if (!response.ok) {
throw new Error('Failed to fetch API keys');
throw new Error("Failed to fetch API keys");
}
const data: { apiKeys: APIKey[] } = await response.json();
setApiKeys(data.apiKeys);
} catch (err) {
console.error('Failed to fetch API keys:', err);
console.error("Failed to fetch API keys:", err);
toast({
title: __("Error"),
description: __("Failed to load API keys"),
@@ -119,32 +130,40 @@ export default function APIKeysPage() {
const fetchData = async () => {
try {
const [apiKeysResponse, organizationsResponse] = await Promise.all([
fetch('/connect/api-keys', { credentials: 'include' }),
fetch('/connect/organizations?role=OWNER', { credentials: 'include' }),
fetch("/connect/api-keys", { credentials: "include" }),
fetch("/connect/organizations?role=OWNER", {
credentials: "include",
}),
]);
if (apiKeysResponse.status === 401 || organizationsResponse.status === 401) {
if (
apiKeysResponse.status === 401 ||
organizationsResponse.status === 401
) {
setError(new UnAuthenticatedError());
return;
}
if (!apiKeysResponse.ok) {
throw new Error('Failed to fetch API keys');
throw new Error("Failed to fetch API keys");
}
if (!organizationsResponse.ok) {
throw new Error('Failed to fetch organizations');
throw new Error("Failed to fetch organizations");
}
const apiKeysData: { apiKeys: APIKey[] } = await apiKeysResponse.json();
const orgsData: { organizations: Organization[] } = await organizationsResponse.json();
const orgsData: { organizations: Organization[] } =
await organizationsResponse.json();
const authenticatedOrgs = orgsData.organizations.filter(org => org.authStatus === "authenticated");
const authenticatedOrgs = orgsData.organizations.filter(
(org) => org.authStatus === "authenticated"
);
setApiKeys(apiKeysData.apiKeys);
setOrganizations(authenticatedOrgs);
} catch (err) {
console.error('Failed to fetch data:', err);
console.error("Failed to fetch data:", err);
toast({
title: __("Error"),
description: __("Failed to load data"),
@@ -179,12 +198,12 @@ export default function APIKeysPage() {
setIsCreating(true);
try {
const response = await fetch('/connect/api-keys', {
method: 'POST',
const response = await fetch("/connect/api-keys", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
name: formData.name,
expiresAt: expiresAt.toISOString(),
@@ -193,7 +212,7 @@ export default function APIKeysPage() {
});
if (!response.ok) {
throw new Error('Failed to create API key');
throw new Error("Failed to create API key");
}
const data: { apiKey: APIKey; key: string } = await response.json();
@@ -224,9 +243,9 @@ export default function APIKeysPage() {
const handleEdit = (apiKey: APIKey) => {
setEditingAPIKey(apiKey);
setEditingName(apiKey.name);
const orgIds = apiKey.organizations.map(org => org.organizationId);
const orgIds = apiKey.organizations.map((org) => org.organizationId);
const roles: Record<string, string> = {};
apiKey.organizations.forEach(org => {
apiKey.organizations.forEach((org) => {
roles[org.organizationId] = org.role;
});
setSelectedOrganizations(orgIds);
@@ -239,16 +258,16 @@ export default function APIKeysPage() {
setIsUpdating(true);
try {
const response = await fetch('/connect/api-keys', {
method: 'PUT',
const response = await fetch("/connect/api-keys", {
method: "PUT",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
id: editingAPIKey.id,
name: editingName,
organizations: selectedOrganizations.map(id => ({
organizations: selectedOrganizations.map((id) => ({
organizationId: id,
role: organizationRoles[id] || "FULL",
})),
@@ -256,7 +275,7 @@ export default function APIKeysPage() {
});
if (!response.ok) {
throw new Error('Failed to update API key');
throw new Error("Failed to update API key");
}
await fetchAPIKeys();
@@ -286,20 +305,20 @@ export default function APIKeysPage() {
async () => {
setIsDeleting(true);
try {
const response = await fetch('/connect/api-keys', {
method: 'DELETE',
const response = await fetch("/connect/api-keys", {
method: "DELETE",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({ id }),
});
if (!response.ok) {
throw new Error('Failed to delete API key');
throw new Error("Failed to delete API key");
}
setApiKeys(apiKeys.filter(key => key.id !== id));
setApiKeys(apiKeys.filter((key) => key.id !== id));
toast({
title: __("Success"),
description: __("API Key deleted successfully"),
@@ -317,7 +336,9 @@ export default function APIKeysPage() {
}
},
{
message: __(`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`),
message: __(
`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`
),
}
);
};
@@ -326,11 +347,11 @@ export default function APIKeysPage() {
setIsLoadingKey(true);
try {
const response = await fetch(`/connect/api-keys/${id}`, {
credentials: 'include',
credentials: "include",
});
if (!response.ok) {
throw new Error('Failed to load API key');
throw new Error("Failed to load API key");
}
const data: { key: string } = await response.json();
@@ -400,7 +421,7 @@ export default function APIKeysPage() {
return (
<Card key={apiKey.id} padded className="w-full">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{apiKey.name}</h3>
@@ -464,7 +485,9 @@ export default function APIKeysPage() {
{__("Create an API key")}
</h2>
<p className="text-txt-tertiary mb-4">
{__("Generate a new API key for programmatic access to your organization")}
{__(
"Generate a new API key for programmatic access to your organization"
)}
</p>
<Button
onClick={() => dialogRef.current?.open()}
@@ -516,31 +539,34 @@ export default function APIKeysPage() {
type="button"
variant="tertiary"
onClick={() => {
const allSelected = selectedOrganizations.length === organizations.length;
const allSelected =
selectedOrganizations.length === organizations.length;
if (allSelected) {
setSelectedOrganizations([]);
setOrganizationRoles({});
setValue("organizations", []);
} else {
const allOrgIds = organizations.map(org => org.id);
const allOrgIds = organizations.map((org) => org.id);
const newRoles: Record<string, string> = {};
allOrgIds.forEach(id => {
allOrgIds.forEach((id) => {
newRoles[id] = organizationRoles[id] || "FULL";
});
setSelectedOrganizations(allOrgIds);
setOrganizationRoles(newRoles);
setValue(
"organizations",
allOrgIds.map(id => ({
allOrgIds.map((id) => ({
organizationId: id,
role: newRoles[id]
role: newRoles[id],
}))
);
}
}}
className="text-xs h-7 min-h-7"
>
{selectedOrganizations.length === organizations.length ? __("Clear All") : __("Select All")}
{selectedOrganizations.length === organizations.length
? __("Clear All")
: __("Select All")}
</Button>
)}
</div>
@@ -556,9 +582,7 @@ export default function APIKeysPage() {
<Th>{__("Name")}</Th>
<Th width={180}>{__("Role")}</Th>
<Th width={100}>
<div className="flex justify-end">
{__("Access")}
</div>
<div className="flex justify-end">{__("Access")}</div>
</Th>
</Tr>
</Thead>
@@ -570,30 +594,33 @@ export default function APIKeysPage() {
{org.name}
</div>
</Td>
<Td>
<div className="min-h-[36px] flex items-center">
{selectedOrganizations.includes(org.id) ? (
<Select
value={organizationRoles[org.id] || "FULL"}
onValueChange={(role) => {
const newRoles = { ...organizationRoles, [org.id]: role };
setOrganizationRoles(newRoles);
setValue(
"organizations",
selectedOrganizations.map(id => ({
organizationId: id,
role: newRoles[id] || "FULL"
}))
);
}}
>
<Option value="FULL">{__("Full")}</Option>
</Select>
) : (
<span className="text-txt-tertiary"></span>
)}
</div>
</Td>
<Td>
<div className="min-h-[36px] flex items-center">
{selectedOrganizations.includes(org.id) ? (
<Select
value={organizationRoles[org.id] || "FULL"}
onValueChange={(role) => {
const newRoles = {
...organizationRoles,
[org.id]: role,
};
setOrganizationRoles(newRoles);
setValue(
"organizations",
selectedOrganizations.map((id) => ({
organizationId: id,
role: newRoles[id] || "FULL",
}))
);
}}
>
<Option value="FULL">{__("Full")}</Option>
</Select>
) : (
<span className="text-txt-tertiary"></span>
)}
</div>
</Td>
<Td>
<div className="flex justify-end">
<Checkbox
@@ -602,21 +629,26 @@ export default function APIKeysPage() {
let newSelected: string[];
const newRoles = { ...organizationRoles };
if (checked) {
newSelected = [...selectedOrganizations, org.id];
newSelected = [
...selectedOrganizations,
org.id,
];
if (!newRoles[org.id]) {
newRoles[org.id] = "FULL";
}
} else {
newSelected = selectedOrganizations.filter(id => id !== org.id);
newSelected = selectedOrganizations.filter(
(id) => id !== org.id
);
delete newRoles[org.id];
}
setSelectedOrganizations(newSelected);
setOrganizationRoles(newRoles);
setValue(
"organizations",
newSelected.map(id => ({
newSelected.map((id) => ({
organizationId: id,
role: newRoles[id] || "FULL"
role: newRoles[id] || "FULL",
}))
);
}}
@@ -660,14 +692,15 @@ export default function APIKeysPage() {
type="button"
variant="tertiary"
onClick={() => {
const allSelected = selectedOrganizations.length === organizations.length;
const allSelected =
selectedOrganizations.length === organizations.length;
if (allSelected) {
setSelectedOrganizations([]);
setOrganizationRoles({});
} else {
const allOrgIds = organizations.map(org => org.id);
const allOrgIds = organizations.map((org) => org.id);
const newRoles: Record<string, string> = {};
allOrgIds.forEach(id => {
allOrgIds.forEach((id) => {
newRoles[id] = organizationRoles[id] || "FULL";
});
setSelectedOrganizations(allOrgIds);
@@ -676,7 +709,9 @@ export default function APIKeysPage() {
}}
className="text-xs h-7 min-h-7"
>
{selectedOrganizations.length === organizations.length ? __("Clear All") : __("Select All")}
{selectedOrganizations.length === organizations.length
? __("Clear All")
: __("Select All")}
</Button>
)}
</div>
@@ -692,9 +727,7 @@ export default function APIKeysPage() {
<Th>{__("Name")}</Th>
<Th width={180}>{__("Role")}</Th>
<Th width={100}>
<div className="flex justify-end">
{__("Access")}
</div>
<div className="flex justify-end">{__("Access")}</div>
</Th>
</Tr>
</Thead>
@@ -712,7 +745,10 @@ export default function APIKeysPage() {
<Select
value={organizationRoles[org.id] || "FULL"}
onValueChange={(role) => {
const newRoles = { ...organizationRoles, [org.id]: role };
const newRoles = {
...organizationRoles,
[org.id]: role,
};
setOrganizationRoles(newRoles);
}}
>
@@ -731,12 +767,17 @@ export default function APIKeysPage() {
let newSelected: string[];
const newRoles = { ...organizationRoles };
if (checked) {
newSelected = [...selectedOrganizations, org.id];
newSelected = [
...selectedOrganizations,
org.id,
];
if (!newRoles[org.id]) {
newRoles[org.id] = "FULL";
}
} else {
newSelected = selectedOrganizations.filter(id => id !== org.id);
newSelected = selectedOrganizations.filter(
(id) => id !== org.id
);
delete newRoles[org.id];
}
setSelectedOrganizations(newSelected);
@@ -754,7 +795,13 @@ export default function APIKeysPage() {
</div>
</DialogContent>
<DialogFooter>
<Button onClick={handleUpdate} disabled={isUpdating || (selectedOrganizations.length === 0 && !editingName.trim())}>
<Button
onClick={handleUpdate}
disabled={
isUpdating ||
(selectedOrganizations.length === 0 && !editingName.trim())
}
>
{isUpdating ? __("Updating...") : __("Update")}
</Button>
</DialogFooter>

View File

@@ -10,7 +10,6 @@ import {
IconCircleCheck,
} from "@probo/ui";
import { PDFPreview } from "../components/documents/PDFPreview";
import { buildEndpoint } from "/providers/RelayProviders";
import { useWindowSize } from "usehooks-ts";
import clsx from "clsx";
import { sprintf } from "@probo/helpers";
@@ -63,7 +62,7 @@ export default function DocumentSigningRequestsPage() {
async function fetchDocuments() {
try {
const response = await fetch(
buildEndpoint("/api/console/v1/documents/signing-requests"),
"/api/console/v1/documents/signing-requests",
{
method: "GET",
headers: {
@@ -114,9 +113,7 @@ export default function DocumentSigningRequestsPage() {
setSigning(true);
try {
const response = await fetch(
buildEndpoint(
`/api/console/v1/documents/signing-requests/${docToSign.document_version_id}/sign`
),
`/api/console/v1/documents/signing-requests/${docToSign.document_version_id}/sign`,
{
method: "POST",
headers: {
@@ -235,9 +232,7 @@ export default function DocumentSigningRequestsPage() {
// Build PDF URL with watermark
const pdfUrl = token
? `${buildEndpoint(
`/api/console/v1/documents/signing-requests/${currentDoc.document_version_id}/pdf`
)}?token=${encodeURIComponent(token)}`
? `${`/api/console/v1/documents/signing-requests/${currentDoc.document_version_id}/pdf`}?token=${encodeURIComponent(token)}`
: null;
return (
@@ -250,7 +245,10 @@ export default function DocumentSigningRequestsPage() {
<div className="grid lg:grid-cols-2 min-h-0 h-full">
<div className="max-w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide">
<h1 className="text-2xl font-semibold mb-6">
{sprintf(__("%s requests your signature"), signingData.organizationName)}
{sprintf(
__("%s requests your signature"),
signingData.organizationName
)}
</h1>
{allSigned ? (
<p className="text-txt-secondary text-base">
@@ -266,7 +264,10 @@ export default function DocumentSigningRequestsPage() {
<Card className="mb-6 overflow-hidden">
<div className="divide-y divide-border-solid">
{(() => {
const renderDocumentItem = (doc: Document, index: number) => (
const renderDocumentItem = (
doc: Document,
index: number
) => (
<div
key={doc.document_version_id}
className={clsx(
@@ -307,15 +308,15 @@ export default function DocumentSigningRequestsPage() {
doc.signed
? "bg-green-100 text-green-800"
: index === currentDocIndex
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-700"
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-700"
)}
>
{doc.signed
? __("Signed")
: index === currentDocIndex
? __("In review")
: __("Waiting signature")}
? __("In review")
: __("Waiting signature")}
</span>
</div>
</div>
@@ -352,8 +353,12 @@ export default function DocumentSigningRequestsPage() {
const currentIsLast = currentDocIndex === totalDocs - 1;
// Calculate hidden docs before and after current
const hiddenBeforeCurrent = currentIsFirst ? 0 : currentDocIndex - 1;
const hiddenAfterCurrent = currentIsLast ? 0 : totalDocs - currentDocIndex - 2;
const hiddenBeforeCurrent = currentIsFirst
? 0
: currentDocIndex - 1;
const hiddenAfterCurrent = currentIsLast
? 0
: totalDocs - currentDocIndex - 2;
return (
<>
@@ -367,12 +372,16 @@ export default function DocumentSigningRequestsPage() {
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
>
<span className="text-txt-tertiary"></span>
{sprintf(__("Show %s more documents"), hiddenBeforeCurrent)}
{sprintf(
__("Show %s more documents"),
hiddenBeforeCurrent
)}
</button>
)}
{/* Current document (if not first) */}
{!currentIsFirst && renderDocumentItem(currentDoc, currentDocIndex)}
{!currentIsFirst &&
renderDocumentItem(currentDoc, currentDocIndex)}
{/* Show more button for documents AFTER current (upcoming documents) */}
{hiddenAfterCurrent > 0 && (
@@ -381,7 +390,10 @@ export default function DocumentSigningRequestsPage() {
className="w-full py-3 px-4 text-sm text-txt-secondary hover:bg-level-1 transition-colors text-left flex items-center gap-2"
>
<span className="text-txt-tertiary"></span>
{sprintf(__("Show %s more documents"), hiddenAfterCurrent)}
{sprintf(
__("Show %s more documents"),
hiddenAfterCurrent
)}
</button>
)}
</>
@@ -390,9 +402,7 @@ export default function DocumentSigningRequestsPage() {
</div>
</Card>
<p className="text-txt-secondary text-sm mb-6">
{__(
"Please review the document carefully before signing."
)}
{__("Please review the document carefully before signing.")}
</p>
</>
)}
@@ -421,10 +431,7 @@ export default function DocumentSigningRequestsPage() {
</>
)}
{currentDoc.signed && !isLastDocument && (
<Button
onClick={handleNextDocument}
className="h-10 w-full mt-4"
>
<Button onClick={handleNextDocument} className="h-10 w-full mt-4">
{__("Next Document")}
</Button>
)}

View File

@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
import { useState } from "react";
const schema = z.object({
@@ -22,17 +21,14 @@ export default function ForgotPasswordPage() {
});
const onSubmit = handleSubmit(async (data) => {
const response = await fetch(
buildEndpoint("/connect/forget-password"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
}
);
const response = await fetch("/connect/forget-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));

View File

@@ -3,7 +3,6 @@ import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
import { useState } from "react";
import { Link, useSearchParams } from "react-router";
import { buildEndpoint } from "/providers/RelayProviders";
export default function LoginPage() {
const { __ } = useTranslate();
@@ -33,7 +32,7 @@ export default function LoginPage() {
setIsLoading(true);
try {
const res = await fetch(buildEndpoint("/connect/login"), {
const res = await fetch("/connect/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -68,7 +67,7 @@ export default function LoginPage() {
setIsChecking(true);
try {
const res = await fetch(buildEndpoint("/connect/check-sso"), {
const res = await fetch("/connect/check-sso", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -78,15 +77,15 @@ export default function LoginPage() {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || __("SSO not available for this email domain"));
throw new Error(
error.message || __("SSO not available for this email domain")
);
}
const data = await res.json();
if (data.ssoAvailable && data.samlConfigId) {
window.location.href = buildEndpoint(
`/connect/saml/login/${data.samlConfigId}`
);
window.location.href = `/connect/saml/login/${data.samlConfigId}`;
} else {
throw new Error(__("SSO not available for this email domain"));
}
@@ -115,10 +114,7 @@ export default function LoginPage() {
{__("Choose your login method")}
</p>
<Button
className="w-full"
onClick={() => setMode("password")}
>
<Button className="w-full" onClick={() => setMode("password")}>
{__("Login with Email")}
</Button>
@@ -146,7 +142,10 @@ export default function LoginPage() {
<div className="text-center mt-6 text-sm text-txt-secondary">
{__("Don't have an account ?")}{" "}
<Link to="/auth/register" className="underline hover:text-txt-primary">
<Link
to="/auth/register"
className="underline hover:text-txt-primary"
>
{__("Register")}
</Link>
</div>
@@ -206,7 +205,10 @@ export default function LoginPage() {
<div className="text-center mt-6 text-sm text-txt-secondary">
{__("Don't have an account ?")}{" "}
<Link to="/auth/register" className="underline hover:text-txt-primary">
<Link
to="/auth/register"
className="underline hover:text-txt-primary"
>
{__("Register")}
</Link>
</div>
@@ -235,9 +237,7 @@ export default function LoginPage() {
<span className="text-sm">{__("Back")}</span>
</button>
<h1 className="text-center text-2xl font-bold">
{__("Login with SSO")}
</h1>
<h1 className="text-center text-2xl font-bold">{__("Login with SSO")}</h1>
<p className="text-center text-txt-tertiary mt-1 mb-6">
{__("Enter your work email to continue with SSO")}
</p>

View File

@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
const schema = z.object({
email: z.string().email(),
@@ -25,17 +24,14 @@ export default function RegisterPage() {
});
const onSubmit = handleSubmit(async (data) => {
const response = await fetch(
buildEndpoint("/connect/register"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
const response = await fetch("/connect/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
credentials: "include",
body: JSON.stringify(data),
});
// Registration failed
if (!response.ok) {

View File

@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
const schema = z
.object({
@@ -20,15 +19,12 @@ export default function ResetPasswordPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast();
const { register, handleSubmit, formState } = useFormWithSchema(
schema,
{
defaultValues: {
password: "",
confirmPassword: "",
},
}
);
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: {
password: "",
confirmPassword: "",
},
});
const onSubmit = handleSubmit(async (data) => {
const searchParams = new URLSearchParams(location.search);
@@ -43,20 +39,17 @@ export default function ResetPasswordPage() {
return;
}
const response = await fetch(
buildEndpoint("/connect/reset-password"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
token: token,
password: data.password,
}),
}
);
const response = await fetch("/connect/reset-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
token: token,
password: data.password,
}),
});
// Reset failed
if (!response.ok) {

View File

@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
import { useEffect } from "react";
const schema = z.object({
@@ -50,21 +49,18 @@ export default function SignupFromInvitationPage() {
return;
}
const response = await fetch(
buildEndpoint("/connect/signup-from-invitation"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
token: token,
password: data.password,
fullName: data.fullName,
}),
}
);
const response = await fetch("/connect/signup-from-invitation", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
token: token,
password: data.password,
fullName: data.fullName,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -78,7 +74,9 @@ export default function SignupFromInvitationPage() {
toast({
title: __("Success"),
description: __("Account created successfully. Please accept your invitation to join the organization."),
description: __(
"Account created successfully. Please accept your invitation to join the organization."
),
variant: "success",
});
navigate("/", { replace: true });

View File

@@ -1,242 +0,0 @@
import {
Environment,
type FetchFunction,
Network,
RecordSource,
Store,
} from "relay-runtime";
import { GraphQLError } from "graphql";
import type { PropsWithChildren } from "react";
import { RelayEnvironmentProvider } from "react-relay";
export class UnAuthenticatedError extends Error {
constructor(message?: string) {
super(message || "UNAUTHENTICATED");
this.name = "UnAuthenticatedError";
}
}
export class InternalServerError extends Error {
constructor() {
super("INTERNAL_SERVER_ERROR");
this.name = "InternalServerError";
}
}
export class AuthenticationRequiredError extends Error {
public redirectUrl: string;
public requiresSaml: boolean;
public organizationId: string;
public samlConfigId?: string;
constructor(extensions: {
redirectUrl: string;
requiresSaml: boolean;
organizationId: string;
samlConfigId?: string;
}) {
super("AUTHENTICATION_REQUIRED");
this.name = "AuthenticationRequiredError";
this.redirectUrl = extensions.redirectUrl;
this.requiresSaml = extensions.requiresSaml;
this.organizationId = extensions.organizationId;
this.samlConfigId = extensions.samlConfigId;
}
}
export class UnauthorizedError extends Error {
constructor(message?: string) {
super(message || "UNAUTHORIZED");
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message || "FORBIDDEN");
this.name = "ForbiddenError";
}
}
export class InvalidError extends Error {
field?: string;
cause?: string;
constructor(message?: string, field?: string, cause?: string) {
super(message || "INVALID");
this.name = "InvalidError";
this.field = field;
this.cause = cause;
}
}
export function buildEndpoint(path: string): string {
const host = import.meta.env.VITE_API_URL;
if (!host) {
return path;
}
const formattedHost =
host.startsWith("http://") || host.startsWith("https://")
? host
: `https://${host}`;
const url = new URL(formattedHost);
if (path) {
url.pathname = path.startsWith("/") ? path : `/${path}`;
}
return url.toString();
}
const hasUnauthenticatedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHENTICATED";
const hasAuthenticationRequiredError = (error: GraphQLError) =>
error.extensions?.code == "AUTHENTICATION_REQUIRED";
const hasUnauthorizedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHORIZED";
const hasForbiddenError = (error: GraphQLError) =>
error.extensions?.code == "FORBIDDEN";
const hasInvalidError = (error: GraphQLError) =>
error.extensions?.code == "INVALID_REQUEST";
const fetchRelay: FetchFunction = async (
request,
variables,
_,
uploadables
) => {
const requestInit: RequestInit = {
method: "POST",
credentials: "include",
headers: {},
};
if (uploadables) {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: request.name,
query: request.text,
variables: variables,
})
);
const uploadableMap: {
[key: string]: string[];
} = {};
Object.keys(uploadables).forEach((key, index) => {
uploadableMap[index] = [`variables.${key}`];
});
formData.append("map", JSON.stringify(uploadableMap));
Object.keys(uploadables).forEach((key, index) => {
formData.append(index.toString(), uploadables[key]);
});
requestInit.body = formData;
} else {
requestInit.headers = {
Accept:
"application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
};
requestInit.body = JSON.stringify({
operationName: request.name,
query: request.text,
variables,
});
}
const response = await fetch(
buildEndpoint("/api/console/v1/query"),
requestInit
);
if (response.status === 500) {
throw new InternalServerError();
}
const json = await response.json();
if (json.errors) {
const errors = json.errors as GraphQLError[];
const unauthenticatedError = errors.find(hasUnauthenticatedError);
if (unauthenticatedError) {
throw new UnAuthenticatedError(unauthenticatedError.message);
}
const authRequiredError = errors.find(hasAuthenticationRequiredError);
if (authRequiredError?.extensions) {
const { redirectUrl, requiresSaml, organizationId, samlConfigId } = authRequiredError.extensions;
throw new AuthenticationRequiredError({
redirectUrl: redirectUrl as string,
requiresSaml: requiresSaml as boolean,
organizationId: organizationId as string,
samlConfigId: samlConfigId as string | undefined,
});
}
const unauthorizedError = errors.find(hasUnauthorizedError);
if (unauthorizedError) {
throw new UnauthorizedError(unauthorizedError.message);
}
const forbiddenError = errors.find(hasForbiddenError);
if (forbiddenError) {
throw new ForbiddenError(forbiddenError.message);
}
const invalidError = errors.find(hasInvalidError);
if (invalidError) {
throw new InvalidError(
invalidError.message,
invalidError.extensions.field as string ?? "",
invalidError.extensions.cause as string ?? "",
);
}
}
return json;
};
const source = new RecordSource();
const store = new Store(source, {
queryCacheExpirationTime: 1 * 60 * 1000,
gcReleaseBufferSize: 20,
});
export const relayEnvironment = new Environment({
network: Network.create(fetchRelay),
store,
});
export const clearRelayStore = () => {
const source = relayEnvironment.getStore().getSource();
if (source instanceof Map) {
source.clear();
}
};
/**
* Provider for relay with the probo environment
*/
export function RelayProvider({ children }: PropsWithChildren) {
return (
<RelayEnvironmentProvider environment={relayEnvironment}>
{children}
</RelayEnvironmentProvider>
);
}

View File

@@ -7,12 +7,6 @@ import {
import { MainLayout } from "./layouts/MainLayout";
import { EmployeeLayout } from "./layouts/EmployeeLayout";
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
import {
relayEnvironment,
UnAuthenticatedError,
UnauthorizedError,
ForbiddenError,
} from "./providers/RelayProviders";
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
import { loadQuery } from "react-relay";
import { riskRoutes } from "./routes/riskRoutes.ts";
@@ -37,12 +31,24 @@ import { rightsRequestRoutes } from "./routes/rightsRequestRoutes.ts";
import { processingActivityRoutes } from "./routes/processingActivityRoutes.ts";
import { statesOfApplicabilityRoutes } from "./routes/statesOfApplicabilityRoutes.ts";
import { lazy } from "@probo/react-lazy";
import { loaderFromQueryLoader, routeFromAppRoute, withQueryRef, type AppRoute } from "@probo/routes";
import {
loaderFromQueryLoader,
routeFromAppRoute,
withQueryRef,
type AppRoute,
} from "@probo/routes";
import { employeeDocumentsQuery } from "./pages/organizations/employee/EmployeeDocumentsPage";
import { employeeDocumentSignatureQuery } from "./pages/organizations/employee/EmployeeDocumentSignaturePage";
import { Role } from "@probo/helpers";
import { PermissionsContext } from "./providers/PermissionsContext";
import { use } from "react";
import { RelayEnvironmentProvider } from "react-relay";
import { connectEnvironment, consoleEnvironment } from "./environments.ts";
import {
ForbiddenError,
UnAuthenticatedError,
UnauthorizedError,
} from "@probo/relay";
/**
* Top level error boundary
@@ -68,7 +74,11 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
const routes = [
{
path: "/auth",
Component: AuthLayout,
Component: () => (
<RelayEnvironmentProvider environment={connectEnvironment}>
<AuthLayout />
</RelayEnvironmentProvider>
),
children: [
{
path: "login",
@@ -98,7 +108,11 @@ const routes = [
},
{
path: "/",
Component: CenteredLayout,
Component: () => (
<RelayEnvironmentProvider environment={consoleEnvironment}>
<CenteredLayout />
</RelayEnvironmentProvider>
),
Fallback: CenteredLayoutSkeleton,
ErrorBoundary: ErrorBoundary,
children: [
@@ -132,35 +146,42 @@ const routes = [
{
path: "",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(
({ organizationId }) =>
loadQuery(relayEnvironment, employeeDocumentsQuery, {
organizationId: organizationId!,
})
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery(consoleEnvironment, employeeDocumentsQuery, {
organizationId: organizationId!,
})
),
Component: withQueryRef(
lazy(
() => import("./pages/organizations/employee/EmployeeDocumentsPage")
)
),
Component: withQueryRef(lazy(
() => import("./pages/organizations/employee/EmployeeDocumentsPage")
)),
},
{
path: ":documentId",
Fallback: PageSkeleton,
ErrorBoundary: ErrorBoundary,
loader: loaderFromQueryLoader(
({ documentId }) =>
loadQuery(relayEnvironment, employeeDocumentSignatureQuery, {
documentId: documentId!,
})
loader: loaderFromQueryLoader(({ documentId }) =>
loadQuery(consoleEnvironment, employeeDocumentSignatureQuery, {
documentId: documentId!,
})
),
Component: withQueryRef(
lazy(
() =>
import("./pages/organizations/employee/EmployeeDocumentSignaturePage")
)
),
Component: withQueryRef(lazy(
() => import("./pages/organizations/employee/EmployeeDocumentSignaturePage")
)),
},
],
},
{
path: "/organizations/:organizationId",
Component: MainLayout,
Component: () => (
<RelayEnvironmentProvider environment={consoleEnvironment}>
<MainLayout />
</RelayEnvironmentProvider>
),
ErrorBoundary: ErrorBoundary,
children: [
{
@@ -180,13 +201,14 @@ const routes = [
{
path: "settings",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(
({ organizationId }) =>
loadQuery(relayEnvironment, organizationViewQuery, {
organizationId: organizationId!,
})
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery(consoleEnvironment, organizationViewQuery, {
organizationId: organizationId!,
})
),
Component: withQueryRef(
lazy(() => import("./pages/organizations/SettingsPage"))
),
Component: withQueryRef(lazy(() => import("./pages/organizations/SettingsPage"))),
children: [
{
path: "",
@@ -196,19 +218,27 @@ const routes = [
},
{
path: "general",
Component: lazy(() => import("./pages/organizations/settings/GeneralSettingsTab")),
Component: lazy(
() => import("./pages/organizations/settings/GeneralSettingsTab")
),
},
{
path: "members",
Component: lazy(() => import("./pages/organizations/settings/MembersSettingsTab")),
Component: lazy(
() => import("./pages/organizations/settings/MembersSettingsTab")
),
},
{
path: "domain",
Component: lazy(() => import("./pages/organizations/settings/DomainSettingsTab")),
Component: lazy(
() => import("./pages/organizations/settings/DomainSettingsTab")
),
},
{
path: "saml-sso",
Component: lazy(() => import("./pages/organizations/settings/SAMLSettingsTab")),
Component: lazy(
() => import("./pages/organizations/settings/SAMLSettingsTab")
),
},
],
},

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { assetsQuery, assetNodeQuery } from "../hooks/graph/AssetGraph";
@@ -12,7 +12,7 @@ export const assetRoutes = [
path: "assets",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<AssetGraphListQuery>(relayEnvironment, assetsQuery, {
loadQuery<AssetGraphListQuery>(consoleEnvironment, assetsQuery, {
organizationId: organizationId,
snapshotId: null,
}),
@@ -23,7 +23,7 @@ export const assetRoutes = [
path: "snapshots/:snapshotId/assets",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<AssetGraphListQuery>(relayEnvironment, assetsQuery, {
loadQuery<AssetGraphListQuery>(consoleEnvironment, assetsQuery, {
organizationId: organizationId,
snapshotId: snapshotId,
}),
@@ -34,7 +34,7 @@ export const assetRoutes = [
path: "assets/:assetId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ assetId }) =>
loadQuery<AssetGraphNodeQuery>(relayEnvironment, assetNodeQuery, { assetId }),
loadQuery<AssetGraphNodeQuery>(consoleEnvironment, assetNodeQuery, { assetId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/assets/AssetDetailsPage"),
@@ -44,7 +44,7 @@ export const assetRoutes = [
path: "snapshots/:snapshotId/assets/:assetId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ assetId }) =>
loadQuery<AssetGraphNodeQuery>(relayEnvironment, assetNodeQuery, { assetId }),
loadQuery<AssetGraphNodeQuery>(consoleEnvironment, assetNodeQuery, { assetId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/assets/AssetDetailsPage"),

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { auditsQuery, auditNodeQuery } from "../hooks/graph/AuditGraph";
@@ -12,7 +12,7 @@ export const auditRoutes = [
path: "audits",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<AuditGraphListQuery>(relayEnvironment, auditsQuery, { organizationId }),
loadQuery<AuditGraphListQuery>(consoleEnvironment, auditsQuery, { organizationId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/audits/AuditsPage")
@@ -22,7 +22,7 @@ export const auditRoutes = [
path: "audits/:auditId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ auditId }) =>
loadQuery<AuditGraphNodeQuery>(relayEnvironment, auditNodeQuery, { auditId }),
loadQuery<AuditGraphNodeQuery>(consoleEnvironment, auditNodeQuery, { auditId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/audits/AuditDetailsPage")

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { continualImprovementsQuery, continualImprovementNodeQuery } from "/hooks/graph/ContinualImprovementGraph";
@@ -12,7 +12,7 @@ export const continualImprovementRoutes = [
path: "continual-improvements",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ContinualImprovementGraphListQuery>(relayEnvironment, continualImprovementsQuery, {
loadQuery<ContinualImprovementGraphListQuery>(consoleEnvironment, continualImprovementsQuery, {
organizationId,
snapshotId: null
}),
@@ -25,7 +25,7 @@ export const continualImprovementRoutes = [
path: "snapshots/:snapshotId/continual-improvements",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<ContinualImprovementGraphListQuery>(relayEnvironment, continualImprovementsQuery, {
loadQuery<ContinualImprovementGraphListQuery>(consoleEnvironment, continualImprovementsQuery, {
organizationId,
snapshotId,
}),
@@ -38,7 +38,7 @@ export const continualImprovementRoutes = [
path: "continual-improvements/:improvementId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ improvementId }) =>
loadQuery<ContinualImprovementGraphNodeQuery>(relayEnvironment, continualImprovementNodeQuery, {
loadQuery<ContinualImprovementGraphNodeQuery>(consoleEnvironment, continualImprovementNodeQuery, {
continualImprovementId: improvementId!,
}),
),
@@ -50,7 +50,7 @@ export const continualImprovementRoutes = [
path: "snapshots/:snapshotId/continual-improvements/:improvementId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ improvementId }) =>
loadQuery<ContinualImprovementGraphNodeQuery>(relayEnvironment, continualImprovementNodeQuery, {
loadQuery<ContinualImprovementGraphNodeQuery>(consoleEnvironment, continualImprovementNodeQuery, {
continualImprovementId: improvementId!,
}),
),

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { dataQuery, datumNodeQuery } from "../hooks/graph/DatumGraph";
@@ -12,7 +12,7 @@ export const dataRoutes = [
path: "data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<DatumGraphListQuery>(relayEnvironment, dataQuery, {
loadQuery<DatumGraphListQuery>(consoleEnvironment, dataQuery, {
organizationId: organizationId,
snapshotId: null
}),
@@ -25,7 +25,7 @@ export const dataRoutes = [
path: "snapshots/:snapshotId/data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<DatumGraphListQuery>(relayEnvironment, dataQuery, {
loadQuery<DatumGraphListQuery>(consoleEnvironment, dataQuery, {
organizationId,
snapshotId,
}),
@@ -38,7 +38,7 @@ export const dataRoutes = [
path: "data/:dataId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ dataId }) =>
loadQuery<DatumGraphNodeQuery>(relayEnvironment, datumNodeQuery, { dataId }),
loadQuery<DatumGraphNodeQuery>(consoleEnvironment, datumNodeQuery, { dataId }),
),
Component: withQueryRef(lazy(
() => import("../pages/organizations/data/DatumDetailsPage")
@@ -48,7 +48,7 @@ export const dataRoutes = [
path: "snapshots/:snapshotId/data/:dataId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ dataId }) =>
loadQuery<DatumGraphNodeQuery>(relayEnvironment, datumNodeQuery, { dataId }),
loadQuery<DatumGraphNodeQuery>(consoleEnvironment, datumNodeQuery, { dataId }),
),
Component: withQueryRef(lazy(
() => import("../pages/organizations/data/DatumDetailsPage")

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { documentsQuery } from "/hooks/graph/DocumentGraph";
import { documentNodeQuery } from "/hooks/graph/DocumentGraph";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
@@ -58,7 +58,7 @@ export const documentsRoutes = [
path: "documents",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<DocumentGraphListQuery>(relayEnvironment, documentsQuery, { organizationId: organizationId! }),
loadQuery<DocumentGraphListQuery>(consoleEnvironment, documentsQuery, { organizationId: organizationId! }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/documents/DocumentsPage"),
@@ -68,7 +68,7 @@ export const documentsRoutes = [
path: "documents/:documentId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ documentId }) =>
loadQuery<DocumentGraphNodeQuery>(relayEnvironment, documentNodeQuery, { documentId: documentId! }),
loadQuery<DocumentGraphNodeQuery>(consoleEnvironment, documentNodeQuery, { documentId: documentId! }),
),
Component: withQueryRef(lazy(
() => import("../pages/organizations/documents/DocumentDetailPage"),

View File

@@ -1,6 +1,6 @@
import { loadQuery } from "react-relay";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import {
frameworksQuery,
frameworkNodeQuery,
@@ -19,7 +19,7 @@ export const frameworkRoutes = [
path: "frameworks",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<FrameworkGraphListQuery>(relayEnvironment, frameworksQuery, { organizationId: organizationId! }),
loadQuery<FrameworkGraphListQuery>(consoleEnvironment, frameworksQuery, { organizationId: organizationId! }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/frameworks/FrameworksPage")
@@ -29,7 +29,7 @@ export const frameworkRoutes = [
path: "frameworks/:frameworkId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ frameworkId }) =>
loadQuery<FrameworkGraphNodeQuery>(relayEnvironment, frameworkNodeQuery, { frameworkId: frameworkId! }),
loadQuery<FrameworkGraphNodeQuery>(consoleEnvironment, frameworkNodeQuery, { frameworkId: frameworkId! }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/frameworks/FrameworkDetailPage")
@@ -43,7 +43,7 @@ export const frameworkRoutes = [
path: "controls/:controlId",
Fallback: ControlSkeleton,
loader: loaderFromQueryLoader(({ controlId }) =>
loadQuery<FrameworkGraphControlNodeQuery>(relayEnvironment, frameworkControlNodeQuery, { controlId: controlId! }),
loadQuery<FrameworkGraphControlNodeQuery>(consoleEnvironment, frameworkControlNodeQuery, { controlId: controlId! }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/frameworks/FrameworkControlPage")

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { measureNodeQuery, measuresQuery } from "/hooks/graph/MeasureGraph";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { redirect } from "react-router";
@@ -15,7 +15,7 @@ export const measureRoutes = [
path: "measures",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<MeasureGraphListQuery>(relayEnvironment, measuresQuery, { organizationId: organizationId! }),
loadQuery<MeasureGraphListQuery>(consoleEnvironment, measuresQuery, { organizationId: organizationId! }),
),
Component: withQueryRef(lazy(() => import("/pages/organizations/measures/MeasuresPage"))),
children: [
@@ -29,7 +29,7 @@ export const measureRoutes = [
path: "measures/:measureId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ measureId }) =>
loadQuery<MeasureGraphNodeQuery>(relayEnvironment, measureNodeQuery, { measureId: measureId! }),
loadQuery<MeasureGraphNodeQuery>(consoleEnvironment, measureNodeQuery, { measureId: measureId! }),
),
Component: withQueryRef(lazy(() => import("/pages/organizations/measures/MeasureDetailPage"))),
children: [

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { meetingsQuery } from "/hooks/graph/MeetingGraph";
import { meetingNodeQuery } from "/hooks/graph/MeetingGraph";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
@@ -40,7 +40,7 @@ export const meetingsRoutes = [
path: "meetings",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<MeetingGraphListQuery>(relayEnvironment, meetingsQuery, { organizationId }),
loadQuery<MeetingGraphListQuery>(consoleEnvironment, meetingsQuery, { organizationId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/meetings/MeetingsPage"),
@@ -50,7 +50,7 @@ export const meetingsRoutes = [
path: "meetings/:meetingId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ meetingId }) =>
loadQuery<MeetingGraphNodeQuery>(relayEnvironment, meetingNodeQuery, { meetingId }),
loadQuery<MeetingGraphNodeQuery>(consoleEnvironment, meetingNodeQuery, { meetingId }),
),
Component: withQueryRef(lazy(
() => import("../pages/organizations/meetings/MeetingDetailPage"),

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { nonconformitiesQuery, nonconformityNodeQuery } from "../hooks/graph/NonconformityGraph";
@@ -12,7 +12,7 @@ export const nonconformityRoutes = [
path: "nonconformities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<NonconformityGraphListQuery>(relayEnvironment, nonconformitiesQuery, {
loadQuery<NonconformityGraphListQuery>(consoleEnvironment, nonconformitiesQuery, {
organizationId,
snapshotId: null
}),
@@ -25,7 +25,7 @@ export const nonconformityRoutes = [
path: "snapshots/:snapshotId/nonconformities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<NonconformityGraphListQuery>(relayEnvironment, nonconformitiesQuery, {
loadQuery<NonconformityGraphListQuery>(consoleEnvironment, nonconformitiesQuery, {
organizationId,
snapshotId,
}),
@@ -38,7 +38,7 @@ export const nonconformityRoutes = [
path: "nonconformities/:nonconformityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ nonconformityId }) =>
loadQuery<NonconformityGraphNodeQuery>(relayEnvironment, nonconformityNodeQuery, {
loadQuery<NonconformityGraphNodeQuery>(consoleEnvironment, nonconformityNodeQuery, {
nonconformityId,
}),
),
@@ -50,7 +50,7 @@ export const nonconformityRoutes = [
path: "snapshots/:snapshotId/nonconformities/:nonconformityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ nonconformityId }) =>
loadQuery<NonconformityGraphNodeQuery>(relayEnvironment, nonconformityNodeQuery, {
loadQuery<NonconformityGraphNodeQuery>(consoleEnvironment, nonconformityNodeQuery, {
nonconformityId: nonconformityId
}),
),

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { obligationsQuery, obligationNodeQuery } from "/hooks/graph/ObligationGraph";
@@ -12,7 +12,7 @@ export const obligationRoutes = [
path: "obligations",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ObligationGraphListQuery>(relayEnvironment, obligationsQuery, {
loadQuery<ObligationGraphListQuery>(consoleEnvironment, obligationsQuery, {
organizationId,
snapshotId: null
}),
@@ -25,7 +25,7 @@ export const obligationRoutes = [
path: "snapshots/:snapshotId/obligations",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<ObligationGraphListQuery>(relayEnvironment, obligationsQuery, {
loadQuery<ObligationGraphListQuery>(consoleEnvironment, obligationsQuery, {
organizationId,
snapshotId,
}),
@@ -38,7 +38,7 @@ export const obligationRoutes = [
path: "obligations/:obligationId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ obligationId }) =>
loadQuery<ObligationGraphNodeQuery>(relayEnvironment, obligationNodeQuery, {
loadQuery<ObligationGraphNodeQuery>(consoleEnvironment, obligationNodeQuery, {
obligationId,
}),
),
@@ -50,7 +50,7 @@ export const obligationRoutes = [
path: "snapshots/:snapshotId/obligations/:obligationId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ obligationId }) =>
loadQuery<ObligationGraphNodeQuery>(relayEnvironment, obligationNodeQuery, {
loadQuery<ObligationGraphNodeQuery>(consoleEnvironment, obligationNodeQuery, {
obligationId,
}),
),

View File

@@ -1,6 +1,6 @@
import { lazy } from "@probo/react-lazy";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton.tsx";
import {
paginatedPeopleQuery,
@@ -16,7 +16,7 @@ export const peopleRoutes = [
path: "people",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<PeopleGraphPaginatedQuery>(relayEnvironment, paginatedPeopleQuery, { organizationId: organizationId! }),
loadQuery<PeopleGraphPaginatedQuery>(consoleEnvironment, paginatedPeopleQuery, { organizationId: organizationId! }),
),
Component: withQueryRef(lazy(() => import("/pages/organizations/people/PeopleListPage"))),
},
@@ -24,7 +24,7 @@ export const peopleRoutes = [
path: "people/:peopleId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ peopleId }) =>
loadQuery<PeopleGraphNodeQuery>(relayEnvironment, peopleNodeQuery, { peopleId: peopleId! }),
loadQuery<PeopleGraphNodeQuery>(consoleEnvironment, peopleNodeQuery, { peopleId: peopleId! }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/people/PeopleDetailPage")

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { lazy } from "@probo/react-lazy";
import { processingActivitiesQuery, processingActivityNodeQuery } from "/hooks/graph/ProcessingActivityGraph";
@@ -12,7 +12,7 @@ export const processingActivityRoutes = [
path: "processing-activities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ProcessingActivityGraphListQuery>(relayEnvironment, processingActivitiesQuery, {
loadQuery<ProcessingActivityGraphListQuery>(consoleEnvironment, processingActivitiesQuery, {
organizationId: organizationId!,
snapshotId: null
}),
@@ -25,7 +25,7 @@ export const processingActivityRoutes = [
path: "snapshots/:snapshotId/processing-activities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<ProcessingActivityGraphListQuery>(relayEnvironment, processingActivitiesQuery, {
loadQuery<ProcessingActivityGraphListQuery>(consoleEnvironment, processingActivitiesQuery, {
organizationId: organizationId!,
snapshotId,
}),
@@ -38,7 +38,7 @@ export const processingActivityRoutes = [
path: "processing-activities/:activityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ activityId }) =>
loadQuery<ProcessingActivityGraphNodeQuery>(relayEnvironment, processingActivityNodeQuery, {
loadQuery<ProcessingActivityGraphNodeQuery>(consoleEnvironment, processingActivityNodeQuery, {
processingActivityId: activityId!,
}),
),
@@ -50,7 +50,7 @@ export const processingActivityRoutes = [
path: "snapshots/:snapshotId/processing-activities/:activityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ activityId }) =>
loadQuery<ProcessingActivityGraphNodeQuery>(relayEnvironment, processingActivityNodeQuery, {
loadQuery<ProcessingActivityGraphNodeQuery>(consoleEnvironment, processingActivityNodeQuery, {
processingActivityId: activityId!,
}),
),

View File

@@ -1,7 +1,7 @@
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { RisksPageSkeleton } from "/components/skeletons/RisksPageSkeleton.tsx";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { riskNodeQuery, risksQuery } from "/hooks/graph/RiskGraph";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { redirect } from "react-router";
@@ -16,7 +16,7 @@ export const riskRoutes = [
path: "risks",
Fallback: RisksPageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<RiskGraphListQuery>(relayEnvironment, risksQuery, {
loadQuery<RiskGraphListQuery>(consoleEnvironment, risksQuery, {
organizationId: organizationId!,
snapshotId: null
}),
@@ -27,7 +27,7 @@ export const riskRoutes = [
path: "snapshots/:snapshotId/risks",
Fallback: RisksPageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<RiskGraphListQuery>(relayEnvironment, risksQuery, {
loadQuery<RiskGraphListQuery>(consoleEnvironment, risksQuery, {
organizationId: organizationId!,
snapshotId: snapshotId!
}),
@@ -38,7 +38,7 @@ export const riskRoutes = [
path: "risks/:riskId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ riskId }) =>
loadQuery<RiskGraphNodeQuery>(relayEnvironment, riskNodeQuery, { riskId: riskId! }),
loadQuery<RiskGraphNodeQuery>(consoleEnvironment, riskNodeQuery, { riskId: riskId! }),
),
Component: withQueryRef(lazy(() => import("/pages/organizations/risks/RiskDetailPage"))),
children: [
@@ -90,7 +90,7 @@ export const riskRoutes = [
path: "snapshots/:snapshotId/risks/:riskId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ riskId }) =>
loadQuery<RiskGraphNodeQuery>(relayEnvironment, riskNodeQuery, { riskId: riskId! }),
loadQuery<RiskGraphNodeQuery>(consoleEnvironment, riskNodeQuery, { riskId: riskId! }),
),
Component: withQueryRef(lazy(() => import("/pages/organizations/risks/RiskDetailPage"))),
children: [

View File

@@ -1,6 +1,6 @@
import { loadQuery } from "react-relay";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { snapshotsQuery, snapshotNodeQuery } from "/hooks/graph/SnapshotGraph";
import { lazy } from "@probo/react-lazy";
import type { SnapshotGraphListQuery } from "/hooks/graph/__generated__/SnapshotGraphListQuery.graphql";
@@ -12,7 +12,7 @@ export const snapshotsRoutes = [
path: "snapshots",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<SnapshotGraphListQuery>(relayEnvironment, snapshotsQuery, { organizationId }),
loadQuery<SnapshotGraphListQuery>(consoleEnvironment, snapshotsQuery, { organizationId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/snapshots/SnapshotsPage")
@@ -22,7 +22,7 @@ export const snapshotsRoutes = [
path: "snapshots/:snapshotId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ snapshotId }) =>
loadQuery<SnapshotGraphNodeQuery>(relayEnvironment, snapshotNodeQuery, { snapshotId }),
loadQuery<SnapshotGraphNodeQuery>(consoleEnvironment, snapshotNodeQuery, { snapshotId }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/snapshots/SnapshotDetailPage")

View File

@@ -1,7 +1,7 @@
import { lazy } from "@probo/react-lazy";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { tasksQuery } from "/hooks/graph/TaskGraph";
import { loaderFromQueryLoader, withQueryRef, type AppRoute } from "@probo/routes";
import type { TaskGraphQuery } from "/hooks/graph/__generated__/TaskGraphQuery.graphql";
@@ -10,7 +10,7 @@ export const taskRoutes = [
path: "tasks",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<TaskGraphQuery>(relayEnvironment, tasksQuery, {
loadQuery<TaskGraphQuery>(consoleEnvironment, tasksQuery, {
organizationId,
}),
),

View File

@@ -1,5 +1,5 @@
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { LinkCardSkeleton } from "/components/skeletons/LinkCardSkeleton";
import { lazy } from "@probo/react-lazy";
@@ -12,7 +12,7 @@ export const trustCenterRoutes = [
path: "trust-center",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<TrustCenterGraphQuery>(relayEnvironment, trustCenterQuery, { organizationId }, { fetchPolicy: "network-only" }),
loadQuery<TrustCenterGraphQuery>(consoleEnvironment, trustCenterQuery, { organizationId }, { fetchPolicy: "network-only" }),
),
Component: withQueryRef(lazy(
() => import("/pages/organizations/trustCenter/TrustCenterPage")

View File

@@ -1,6 +1,6 @@
import { lazy } from "@probo/react-lazy";
import { loadQuery } from "react-relay";
import { relayEnvironment } from "/providers/RelayProviders";
import { consoleEnvironment } from "/environments";
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
import { vendorNodeQuery, vendorsQuery } from "/hooks/graph/VendorGraph";
import { LinkCardSkeleton } from "/components/skeletons/LinkCardSkeleton";
@@ -13,7 +13,7 @@ export const vendorRoutes = [
path: "vendors",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(relayEnvironment, vendorsQuery, {
loadQuery<VendorGraphListQuery>(consoleEnvironment, vendorsQuery, {
organizationId: organizationId!,
snapshotId: null
}),
@@ -24,7 +24,7 @@ export const vendorRoutes = [
path: "snapshots/:snapshotId/vendors",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<VendorGraphListQuery>(relayEnvironment, vendorsQuery, {
loadQuery<VendorGraphListQuery>(consoleEnvironment, vendorsQuery, {
organizationId: organizationId!,
snapshotId
}),
@@ -35,7 +35,7 @@ export const vendorRoutes = [
path: "vendors/:vendorId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(relayEnvironment, vendorNodeQuery, {
loadQuery<VendorGraphNodeQuery>(consoleEnvironment, vendorNodeQuery, {
vendorId: vendorId!,
}),
),
@@ -100,7 +100,7 @@ export const vendorRoutes = [
path: "snapshots/:snapshotId/vendors/:vendorId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(relayEnvironment, vendorNodeQuery, {
loadQuery<VendorGraphNodeQuery>(consoleEnvironment, vendorNodeQuery, {
vendorId: vendorId!,
}),
),

View File

@@ -24,6 +24,7 @@ export default defineConfig({
},
resolve: {
alias: {
"/environments": fileURLToPath(new URL("./src/environments", import.meta.url)),
"/type": fileURLToPath(new URL("./src/type.ts", import.meta.url)),
"/components": fileURLToPath(
new URL("./src/components", import.meta.url),

View File

@@ -66,7 +66,7 @@ const fetchRelay: FetchFunction = async (
request,
variables,
_,
uploadables,
uploadables
) => {
const requestInit: RequestInit = {
method: "POST",
@@ -82,7 +82,7 @@ const fetchRelay: FetchFunction = async (
operationName: request.name,
query: request.text,
variables: variables,
}),
})
);
const uploadableMap: {
@@ -124,7 +124,7 @@ const fetchRelay: FetchFunction = async (
// For custom domains at /overview, this resolves to /api/trust/v1/graphql
const response = await fetch(
buildEndpoint("./api/trust/v1/graphql"),
requestInit,
requestInit
);
if (response.status === 500) {
@@ -161,7 +161,7 @@ const store = new Store(source, {
gcReleaseBufferSize: 20,
});
export const relayEnvironment = new Environment({
export const consoleEnvironment = new Environment({
network: Network.create(fetchRelay),
store,
});
@@ -171,7 +171,7 @@ export const relayEnvironment = new Environment({
*/
export function RelayProvider({ children }: PropsWithChildren) {
return (
<RelayEnvironmentProvider environment={relayEnvironment}>
<RelayEnvironmentProvider environment={consoleEnvironment}>
{children}
</RelayEnvironmentProvider>
);

View File

@@ -6,7 +6,7 @@ import {
} from "react-router";
import { Fragment } from "react";
import {
relayEnvironment,
consoleEnvironment,
UnAuthenticatedError,
} from "./providers/RelayProviders.tsx";
import { loadQuery } from "react-relay";
@@ -23,7 +23,12 @@ import { SubprocessorsPage } from "/pages/SubprocessorsPage";
import { AccessPage } from "./pages/AccessPage.tsx";
import { TabSkeleton } from "./components/Skeletons/TabSkeleton.tsx";
import { MainSkeleton } from "./components/Skeletons/MainSkeleton.tsx";
import { loaderFromQueryLoader, routeFromAppRoute, withQueryRef, type AppRoute } from "@probo/routes";
import {
loaderFromQueryLoader,
routeFromAppRoute,
withQueryRef,
type AppRoute,
} from "@probo/routes";
/**
* Top level error boundary
@@ -50,7 +55,9 @@ const routes = [
// Custom domain routes (subdomain-based)
{
path: "/overview",
loader: loaderFromQueryLoader(() => loadQuery(relayEnvironment, currentTrustGraphQuery, {})),
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustGraphQuery, {})
),
Component: withQueryRef(MainLayout),
Fallback: MainSkeleton,
ErrorBoundary: ErrorBoundary,
@@ -64,14 +71,18 @@ const routes = [
},
{
path: "/documents",
loader: loaderFromQueryLoader(() => loadQuery(relayEnvironment, currentTrustGraphQuery, {})),
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustGraphQuery, {})
),
Component: withQueryRef(MainLayout),
Fallback: MainSkeleton,
ErrorBoundary: ErrorBoundary,
children: [
{
path: "",
loader: loaderFromQueryLoader(() => loadQuery(relayEnvironment, currentTrustDocumentsQuery, {})),
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustDocumentsQuery, {})
),
Fallback: TabSkeleton,
Component: withQueryRef(DocumentsPage),
},
@@ -79,14 +90,18 @@ const routes = [
},
{
path: "/subprocessors",
loader: loaderFromQueryLoader(() => loadQuery(relayEnvironment, currentTrustGraphQuery, {})),
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustGraphQuery, {})
),
Component: withQueryRef(MainLayout),
Fallback: MainSkeleton,
ErrorBoundary: ErrorBoundary,
children: [
{
path: "",
loader: loaderFromQueryLoader(() => loadQuery(relayEnvironment, currentTrustVendorsQuery, {})),
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustVendorsQuery, {})
),
Fallback: TabSkeleton,
Component: withQueryRef(SubprocessorsPage),
},

View File

@@ -13,15 +13,36 @@ probod:
encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA="
chrome-dp-addr: "localhost:9222"
identity-and-access-management:
signup:
enabled: true
invitation-token-validity: 3600
password:
pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes"
session:
duration: 7d
cookie:
name: "SSID"
domain: "localhost"
secret: "this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes"
duration: 24
secure: true
trust-center:
http-addr: ":8085"
https-addr: ":8443"
api:
addr: "localhost:8080"
cors:
allowed-origins: ["http://localhost:8080", "http://localhost:5173"]
extra-header-fields: {}
default:
cors:
allowed-origins: ["http://localhost:8080", "http://localhost:5173"]
extra-header-fields: {}
console:
complaince-page:
connect:
mcp:
pg:
addr: "localhost:5432"

View File

@@ -146,7 +146,6 @@ services:
command:
- "start-dev"
- "--import-realm"
- "--verbose"
ports:
- 8082:8080
volumes:

4
go.sum
View File

@@ -2,14 +2,10 @@ codeberg.org/miekg/dns v0.6.2 h1:gkuKad3tKCs4On/3ZlydASeXPc1uQaZNuDSxEXR7Mt0=
codeberg.org/miekg/dns v0.6.2/go.mod h1:IKSpRNHVdUyxHC457VnDQ9Dv05UhXXsALZAywCM7D54=
github.com/99designs/gqlgen v0.17.84 h1:iVMdiStgUVx/BFkMb0J5GAXlqfqtQ7bqMCYK6v52kQ0=
github.com/99designs/gqlgen v0.17.84/go.mod h1:qjoUqzTeiejdo+bwUg8unqSpeYG42XrcrQboGIezmFA=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=

1969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,5 @@
"packageManager": "npm@11.1.0",
"volta": {
"node": "24.4.0"
},
"dependencies": {
"react-dom": "^19.2.1"
}
}

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@react-email/components": "^0.5.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-email": "^4.3.0"
},
"devDependencies": {

View File

@@ -0,0 +1,31 @@
{
"name": "@probo/relay",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write ./src"
},
"prettier": "@probo/prettier",
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@probo/prettier": "1.0.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
},
"peerDependencies": {
"react": "^19.2.1",
"react-relay": "^19.0.0",
"react-router": "^7.10.0",
"relay-runtime": "^19.0.0"
},
"dependencies": {
"graphql": "^16.12.0"
}
}

View File

@@ -0,0 +1,53 @@
export class UnAuthenticatedError extends Error {
constructor(message?: string) {
super(message || "UNAUTHENTICATED");
this.name = "UnAuthenticatedError";
Object.setPrototypeOf(this, UnAuthenticatedError.prototype);
}
}
export class InternalServerError extends Error {
constructor() {
super("INTERNAL_SERVER_ERROR");
this.name = "InternalServerError";
Object.setPrototypeOf(this, InternalServerError.prototype);
}
}
export class AuthenticationRequiredError extends Error {
public redirectUrl: string;
public requiresSaml: boolean;
public organizationId: string;
public samlConfigId?: string;
constructor(extensions: {
redirectUrl: string;
requiresSaml: boolean;
organizationId: string;
samlConfigId?: string;
}) {
super("AUTHENTICATION_REQUIRED");
this.name = "AuthenticationRequiredError";
Object.setPrototypeOf(this, AuthenticationRequiredError.prototype);
this.redirectUrl = extensions.redirectUrl;
this.requiresSaml = extensions.requiresSaml;
this.organizationId = extensions.organizationId;
this.samlConfigId = extensions.samlConfigId;
}
}
export class UnauthorizedError extends Error {
constructor(message?: string) {
super(message || "UNAUTHORIZED");
this.name = "UnauthorizedError";
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message || "FORBIDDEN");
this.name = "ForbiddenError";
Object.setPrototypeOf(this, ForbiddenError.prototype);
}
}

112
packages/relay/src/fetch.ts Normal file
View File

@@ -0,0 +1,112 @@
import { type FetchFunction } from "relay-runtime";
import {
InternalServerError,
UnAuthenticatedError,
AuthenticationRequiredError,
UnauthorizedError,
ForbiddenError,
} from "./errors";
import { GraphQLError } from "graphql";
const hasUnauthenticatedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHENTICATED";
const hasAuthenticationRequiredError = (error: GraphQLError) =>
error.extensions?.code == "AUTHENTICATION_REQUIRED";
const hasUnauthorizedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHORIZED";
const hasForbiddenError = (error: GraphQLError) =>
error.extensions?.code == "FORBIDDEN";
export const makeFetchQuery = (endpoint: string): FetchFunction => {
return async (request, variables, _, uploadables) => {
const requestInit: RequestInit = {
method: "POST",
credentials: "include",
headers: {},
};
if (uploadables) {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: request.name,
query: request.text,
variables: variables,
}),
);
const uploadableMap: {
[key: string]: string[];
} = {};
Object.keys(uploadables).forEach((key, index) => {
uploadableMap[index] = [`variables.${key}`];
});
formData.append("map", JSON.stringify(uploadableMap));
Object.keys(uploadables).forEach((key, index) => {
formData.append(index.toString(), uploadables[key]);
});
requestInit.body = formData;
} else {
requestInit.headers = {
Accept: "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
};
requestInit.body = JSON.stringify({
operationName: request.name,
query: request.text,
variables,
});
}
const response = await fetch(endpoint, requestInit);
if (response.status === 500) {
throw new InternalServerError();
}
const json = await response.json();
if (json.errors) {
const errors = json.errors as GraphQLError[];
const unauthenticatedError = errors.find(hasUnauthenticatedError);
if (unauthenticatedError) {
throw new UnAuthenticatedError(unauthenticatedError.message);
}
const authRequiredError = errors.find(hasAuthenticationRequiredError);
if (authRequiredError?.extensions) {
const { redirectUrl, requiresSaml, organizationId, samlConfigId } =
authRequiredError.extensions;
throw new AuthenticationRequiredError({
redirectUrl: redirectUrl as string,
requiresSaml: requiresSaml as boolean,
organizationId: organizationId as string,
samlConfigId: samlConfigId as string | undefined,
});
}
const unauthorizedError = errors.find(hasUnauthorizedError);
if (unauthorizedError) {
throw new UnauthorizedError(unauthorizedError.message);
}
const forbiddenError = errors.find(hasForbiddenError);
if (forbiddenError) {
throw new ForbiddenError(forbiddenError.message);
}
}
return json;
};
};

View File

@@ -0,0 +1,2 @@
export { makeFetchQuery } from "./fetch";
export * from "./errors";

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"lib": ["ES2021", "dom"],
"jsx": "react-jsx",
}
}

View File

@@ -1,88 +0,0 @@
package auth
import (
"fmt"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// AuthMethod represents a method of authentication
type AuthMethod int
const (
AuthMethodPassword AuthMethod = iota
AuthMethodSAML
AuthMethodAny
)
type OrgAuthRequirement struct {
OrganizationID gid.GID
EmailDomain string
SAMLConfig *coredata.SAMLConfiguration
}
type AccessResult struct {
OrganizationID gid.GID
Allowed bool
MissingAuth AuthMethod
SAMLConfig *coredata.SAMLConfiguration
}
func (r OrgAuthRequirement) Check(session coredata.SessionData) AccessResult {
if r.SAMLConfig == nil || !r.SAMLConfig.Enabled || !r.SAMLConfig.DomainVerified {
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: session.PasswordAuthenticated,
MissingAuth: AuthMethodPassword,
SAMLConfig: nil,
}
}
orgKey := r.OrganizationID.String()
_, hasSAML := session.SAMLAuthenticatedOrgs[orgKey]
if r.SAMLConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyRequired {
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: hasSAML,
MissingAuth: AuthMethodSAML,
SAMLConfig: r.SAMLConfig,
}
}
hasAnyAuth := session.PasswordAuthenticated || hasSAML
missingAuth := AuthMethodAny
if hasAnyAuth {
missingAuth = AuthMethodPassword
}
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: hasAnyAuth,
MissingAuth: missingAuth,
SAMLConfig: r.SAMLConfig,
}
}
func (r AccessResult) ToError(baseURL string) error {
if r.Allowed {
return nil
}
switch r.MissingAuth {
case AuthMethodPassword:
return ErrPasswordAuthRequired{
OrganizationID: r.OrganizationID,
RedirectURL: fmt.Sprintf("%s/auth/login?method=password", baseURL),
}
case AuthMethodSAML, AuthMethodAny:
return ErrSAMLAuthRequired{
ConfigID: r.SAMLConfig.ID,
OrganizationID: r.OrganizationID,
RedirectURL: fmt.Sprintf("%s/connect/saml/login/%s", baseURL, r.SAMLConfig.ID),
}
default:
return fmt.Errorf("access denied to organization %s", r.OrganizationID)
}
}

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package auth
import (
"context"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
const (
DefaultCleanupInterval = 1 * time.Hour
)
type (
Cleaner struct {
pg *pg.Client
interval time.Duration
logger *log.Logger
}
)
func NewCleaner(
pg *pg.Client,
interval time.Duration,
logger *log.Logger,
) *Cleaner {
if interval == 0 {
interval = DefaultCleanupInterval
}
return &Cleaner{
pg: pg,
interval: interval,
logger: logger.Named("saml.cleaner"),
}
}
func (c *Cleaner) Run(ctx context.Context) error {
c.logger.InfoCtx(ctx, "SAML cleaner starting", log.Duration("interval", c.interval))
if err := c.cleanup(ctx); err != nil {
c.logger.ErrorCtx(ctx, "initial cleanup failed", log.Error(err))
}
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
c.logger.InfoCtx(ctx, "SAML cleaner shutting down")
return ctx.Err()
case <-ticker.C:
if err := c.cleanup(ctx); err != nil {
c.logger.ErrorCtx(ctx, "periodic cleanup failed", log.Error(err))
}
}
}
}
func (c *Cleaner) cleanup(ctx context.Context) error {
var assertionsDeleted, requestsDeleted int64
err := c.pg.WithConn(
ctx,
func(conn pg.Conn) error {
count, err := CleanupExpiredAssertions(ctx, conn)
if err != nil {
return err
}
assertionsDeleted = count
count, err = CleanupExpiredRequests(ctx, conn)
if err != nil {
return err
}
requestsDeleted = count
return nil
},
)
if err != nil {
return err
}
if assertionsDeleted > 0 || requestsDeleted > 0 {
c.logger.InfoCtx(ctx, "cleaned up expired SAML data",
log.Int64("assertions", assertionsDeleted),
log.Int64("requests", requestsDeleted))
}
return nil
}

View File

@@ -1,288 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package auth
import (
"context"
"fmt"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.gearno.de/kit/pg"
)
type (
CreateSAMLConfigurationRequest struct {
OrganizationID gid.GID
EmailDomain string
EnforcementPolicy coredata.SAMLEnforcementPolicy
IdPEntityID string
IdPSsoURL string
IdPCertificate string
IdPMetadataURL *string
AttributeEmail string
AttributeFirstname string
AttributeLastname string
AttributeRole string
AutoSignupEnabled bool
}
UpdateSAMLConfigurationRequest struct {
ID gid.GID
Enabled *bool
EnforcementPolicy *coredata.SAMLEnforcementPolicy
IdPEntityID *string
IdPSsoURL *string
IdPCertificate *string
IdPMetadataURL *string
AttributeEmail *string
AttributeFirstname *string
AttributeLastname *string
AttributeRole *string
AutoSignupEnabled *bool
}
)
func (s TenantAuthService) CreateSAMLConfiguration(
ctx context.Context,
req CreateSAMLConfigurationRequest,
) (*coredata.SAMLConfiguration, error) {
// Validate only the IdP configuration (user-provided data)
if err := ValidateIdPConfiguration(req.IdPEntityID, req.IdPSsoURL, req.IdPCertificate); err != nil {
return nil, fmt.Errorf("SAML configuration validation failed: %w", err)
}
var config *coredata.SAMLConfiguration
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
now := time.Now()
tenantID := s.scope.GetTenantID()
var org coredata.Organization
if err := org.LoadByID(ctx, tx, s.scope, req.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
config = &coredata.SAMLConfiguration{
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
OrganizationID: org.ID,
EmailDomain: req.EmailDomain,
EnforcementPolicy: req.EnforcementPolicy,
Enabled: false,
IdPEntityID: req.IdPEntityID,
IdPSsoURL: req.IdPSsoURL,
IdPCertificate: req.IdPCertificate,
IdPMetadataURL: req.IdPMetadataURL,
AttributeEmail: req.AttributeEmail,
AttributeFirstname: req.AttributeFirstname,
AttributeLastname: req.AttributeLastname,
AttributeRole: req.AttributeRole,
AutoSignupEnabled: req.AutoSignupEnabled,
CreatedAt: now,
UpdatedAt: now,
}
if err := config.Insert(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot insert saml configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return config, nil
}
func (s TenantAuthService) UpdateSAMLConfiguration(
ctx context.Context,
req UpdateSAMLConfigurationRequest,
) (*coredata.SAMLConfiguration, error) {
var config *coredata.SAMLConfiguration
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var cfg coredata.SAMLConfiguration
if err := cfg.LoadByID(ctx, tx, s.scope, req.ID); err != nil {
return fmt.Errorf("cannot load saml configuration: %w", err)
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.EnforcementPolicy != nil {
cfg.EnforcementPolicy = *req.EnforcementPolicy
}
if req.IdPEntityID != nil {
cfg.IdPEntityID = *req.IdPEntityID
}
if req.IdPSsoURL != nil {
cfg.IdPSsoURL = *req.IdPSsoURL
}
if req.IdPCertificate != nil {
cfg.IdPCertificate = *req.IdPCertificate
}
if req.IdPMetadataURL != nil {
cfg.IdPMetadataURL = req.IdPMetadataURL
}
if req.AttributeEmail != nil {
cfg.AttributeEmail = *req.AttributeEmail
}
if req.AttributeFirstname != nil {
cfg.AttributeFirstname = *req.AttributeFirstname
}
if req.AttributeLastname != nil {
cfg.AttributeLastname = *req.AttributeLastname
}
if req.AttributeRole != nil {
cfg.AttributeRole = *req.AttributeRole
}
if req.AutoSignupEnabled != nil {
cfg.AutoSignupEnabled = *req.AutoSignupEnabled
}
cfg.UpdatedAt = time.Now()
if err := cfg.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update saml configuration: %w", err)
}
config = &cfg
return nil
},
)
if err != nil {
return nil, err
}
return config, nil
}
func (s TenantAuthService) DeleteSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var config coredata.SAMLConfiguration
if err := config.LoadByID(ctx, tx, s.scope, configID); err != nil {
return fmt.Errorf("cannot load saml configuration: %w", err)
}
if err := config.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete saml configuration: %w", err)
}
return nil
},
)
}
func (s TenantAuthService) EnableSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
enabled := true
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
ID: configID,
Enabled: &enabled,
})
}
func (s TenantAuthService) DisableSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
disabled := false
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
ID: configID,
Enabled: &disabled,
})
}
func (s TenantAuthService) GetSAMLConfigurationByID(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
var config coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return config.LoadByID(ctx, conn, s.scope, configID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configuration: %w", err)
}
return &config, nil
}
func (s TenantAuthService) GetSAMLConfigurationsByOrganizationID(
ctx context.Context,
organizationID gid.GID,
) ([]*coredata.SAMLConfiguration, error) {
var configs []*coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
configs, err = coredata.LoadSAMLConfigurationsByOrganizationID(ctx, conn, s.scope, organizationID)
return err
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
}
return configs, nil
}
func (s Service) CheckSSOAvailabilityByEmail(
ctx context.Context,
email string,
) ([]*coredata.SAMLConfiguration, error) {
// Extract domain from email
parts := strings.Split(email, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid email format")
}
domain := parts[1]
var configs []*coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
configs, err = coredata.LoadAllEnabledSAMLConfigurationsByEmailDomain(ctx, conn, domain)
return err
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
}
return configs, nil
}

View File

@@ -1,204 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"encoding/xml"
"fmt"
"math/big"
"time"
"github.com/crewjam/saml"
)
func GenerateServiceProviderMetadata(
entityID string,
acsURL string,
spCert *x509.Certificate,
) ([]byte, error) {
certData := base64.StdEncoding.EncodeToString(spCert.Raw)
trueVal := true
metadata := &saml.EntityDescriptor{
EntityID: entityID,
SPSSODescriptors: []saml.SPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: certData},
},
},
},
},
{
Use: "encryption",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: certData},
},
},
},
},
},
},
},
AuthnRequestsSigned: &trueVal,
WantAssertionsSigned: &trueVal,
AssertionConsumerServices: []saml.IndexedEndpoint{
{
Binding: saml.HTTPPostBinding,
Location: acsURL,
Index: 0,
},
},
},
},
}
xmlBytes, err := xml.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("cannot marshal SP metadata to XML: %w", err)
}
return xmlBytes, nil
}
func ParseIdPCertificate(certPEM string) (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, fmt.Errorf("cannot decode PEM block from IdP certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return cert, nil
}
type IdPMetadata struct {
EntityID string
SsoURL string
Certificate string
MetadataURL *string
}
func ParseIdPMetadata(metadataXML string) (*IdPMetadata, error) {
var entityDescriptor saml.EntityDescriptor
if err := xml.Unmarshal([]byte(metadataXML), &entityDescriptor); err != nil {
return nil, fmt.Errorf("cannot parse IdP metadata XML: %w", err)
}
if len(entityDescriptor.IDPSSODescriptors) == 0 {
return nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
}
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
var ssoURL string
for _, sso := range idpDescriptor.SingleSignOnServices {
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
ssoURL = sso.Location
break
}
}
if ssoURL == "" && len(idpDescriptor.SingleSignOnServices) > 0 {
ssoURL = idpDescriptor.SingleSignOnServices[0].Location
}
if ssoURL == "" {
return nil, fmt.Errorf("no SingleSignOnService found in metadata")
}
var certPEM string
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
certDER, err := base64.StdEncoding.DecodeString(certData)
if err != nil {
return nil, fmt.Errorf("cannot decode certificate: %w", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
break
}
}
}
if certPEM == "" {
return nil, fmt.Errorf("no signing certificate found in metadata")
}
return &IdPMetadata{
EntityID: entityDescriptor.EntityID,
SsoURL: ssoURL,
Certificate: certPEM,
}, nil
}
func GenerateSelfSignedCertificate(entityID string) (*x509.Certificate, *rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate RSA private key: %w", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: entityID,
Organization: []string{"Probo"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, fmt.Errorf("cannot create certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse created certificate: %w", err)
}
return cert, privateKey, nil
}

View File

@@ -1,556 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package auth
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/crewjam/saml"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type (
SAMLService struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
baseURL string
sessionDuration time.Duration
cookieName string
cookieSecret string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
}
ErrSPCertificateNotConfigured struct{}
ErrSAMLConfigurationNotFound struct {
OrganizationID gid.GID
}
ErrSAMLDisabled struct {
OrganizationID gid.GID
}
ErrInvalidIdPCertificate struct {
Err error
}
ErrInvalidURL struct {
Field string
URL string
Err error
}
ErrCannotCreateServiceProvider struct {
Err error
}
ErrCannotCreateAuthRequest struct {
Err error
}
ErrCannotGenerateRedirectURL struct {
Err error
}
ErrCannotParseSAMLResponse struct {
Err error
}
ErrCannotValidateAssertion struct {
Err error
}
ErrCannotExtractUserAttributes struct {
Err error
}
ErrCannotMapRole struct {
Err error
}
ErrReplayAttackDetected struct {
AssertionID string
Err error
}
)
func (e ErrSPCertificateNotConfigured) Error() string {
return "SP certificate and private key are not configured"
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration not found for organization %s", e.OrganizationID)
}
func (e ErrSAMLDisabled) Error() string {
return fmt.Sprintf("SAML is disabled for organization %s", e.OrganizationID)
}
func (e ErrInvalidIdPCertificate) Error() string {
return fmt.Sprintf("cannot parse IdP certificate: %v", e.Err)
}
func (e ErrInvalidURL) Error() string {
return fmt.Sprintf("cannot parse %s URL %q: %v", e.Field, e.URL, e.Err)
}
func (e ErrCannotCreateServiceProvider) Error() string {
return fmt.Sprintf("cannot create service provider: %v", e.Err)
}
func (e ErrCannotCreateAuthRequest) Error() string {
return fmt.Sprintf("cannot create AuthnRequest: %v", e.Err)
}
func (e ErrCannotGenerateRedirectURL) Error() string {
return fmt.Sprintf("cannot generate redirect URL: %v", e.Err)
}
func (e ErrCannotParseSAMLResponse) Error() string {
return fmt.Sprintf("cannot parse SAML response: %v", e.Err)
}
func (e ErrCannotValidateAssertion) Error() string {
return fmt.Sprintf("cannot validate assertion: %v", e.Err)
}
func (e ErrCannotExtractUserAttributes) Error() string {
return fmt.Sprintf("cannot extract user attributes: %v", e.Err)
}
func (e ErrCannotMapRole) Error() string {
return fmt.Sprintf("cannot map role: %v", e.Err)
}
func (e ErrReplayAttackDetected) Error() string {
return fmt.Sprintf("replay attack detected for assertion %s: %v", e.AssertionID, e.Err)
}
func NewSAMLService(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
baseURL string,
sessionDuration time.Duration,
cookieName string,
cookieSecret string,
certificatePEM string,
privateKeyPEM string,
logger *log.Logger,
) (*SAMLService, error) {
var certificate *x509.Certificate
var privateKey *rsa.PrivateKey
if certificatePEM != "" {
block, _ := pem.Decode([]byte(certificatePEM))
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("invalid certificate PEM format")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
certificate = cert
}
if privateKeyPEM != "" {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("invalid private key PEM format")
}
var key *rsa.PrivateKey
var err error
switch block.Type {
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse PKCS1 private key: %w", err)
}
case "PRIVATE KEY":
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse PKCS8 private key: %w", err)
}
var ok bool
key, ok = parsedKey.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not RSA")
}
default:
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
}
privateKey = key
}
return &SAMLService{
pg: pg,
encryptionKey: encryptionKey,
baseURL: baseURL,
sessionDuration: sessionDuration,
cookieName: cookieName,
cookieSecret: cookieSecret,
certificate: certificate,
privateKey: privateKey,
logger: logger,
}, nil
}
func (s *SAMLService) GetEntityID() string {
return fmt.Sprintf("%s/connect/saml/metadata", s.baseURL)
}
func (s *SAMLService) GetAcsURL() string {
return fmt.Sprintf("%s/connect/saml/consume", s.baseURL)
}
func (s *SAMLService) GetServiceProvider(
ctx context.Context,
config *coredata.SAMLConfiguration,
) (*saml.ServiceProvider, error) {
if s.certificate == nil || s.privateKey == nil {
return nil, ErrSPCertificateNotConfigured{}
}
idpCert, err := ParseIdPCertificate(config.IdPCertificate)
if err != nil {
return nil, ErrInvalidIdPCertificate{Err: err}
}
acsURL, err := url.Parse(s.GetAcsURL())
if err != nil {
return nil, ErrInvalidURL{Field: "ACS", URL: s.GetAcsURL(), Err: err}
}
metadataURL, err := url.Parse(s.GetEntityID())
if err != nil {
return nil, ErrInvalidURL{Field: "Metadata", URL: s.GetEntityID(), Err: err}
}
idpSSOURL, err := url.Parse(config.IdPSsoURL)
if err != nil {
return nil, ErrInvalidURL{Field: "IdP SSO", URL: config.IdPSsoURL, Err: err}
}
sp := &saml.ServiceProvider{
EntityID: s.GetEntityID(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: *metadataURL,
AcsURL: *acsURL,
SloURL: *acsURL,
AllowIDPInitiated: true,
IDPMetadata: &saml.EntityDescriptor{
EntityID: config.IdPEntityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(idpCert.Raw)},
},
},
},
},
},
},
},
SingleSignOnServices: []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: idpSSOURL.String(),
},
},
},
},
},
}
return sp, nil
}
func (s *SAMLService) InitiateSAMLLogin(
ctx context.Context,
organizationID gid.GID,
tenantID gid.TenantID,
emailDomain string,
) (string, error) {
var config coredata.SAMLConfiguration
scope := coredata.NewScope(tenantID)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return config.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, emailDomain)
},
)
if err != nil {
return "", ErrSAMLConfigurationNotFound{OrganizationID: organizationID}
}
if !config.Enabled {
return "", ErrSAMLDisabled{OrganizationID: organizationID}
}
sp, err := s.GetServiceProvider(ctx, &config)
if err != nil {
return "", ErrCannotCreateServiceProvider{Err: err}
}
authReq, err := sp.MakeAuthenticationRequest(
config.IdPSsoURL,
saml.HTTPRedirectBinding,
saml.HTTPPostBinding,
)
if err != nil {
return "", ErrCannotCreateAuthRequest{Err: err}
}
now := time.Now()
requestExpiry := now.Add(10 * time.Minute)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
samlRequest := coredata.SAMLRequest{
ID: authReq.ID,
OrganizationID: organizationID,
CreatedAt: now,
ExpiresAt: requestExpiry,
}
if err := samlRequest.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert SAML request: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
redirectURL, err := authReq.Redirect(config.ID.String(), sp)
if err != nil {
return "", ErrCannotGenerateRedirectURL{Err: err}
}
return redirectURL.String(), nil
}
type SAMLUserInfo struct {
Email mail.Addr
FullName string
Role *coredata.MembershipRole
SAMLSubject string
OrganizationID gid.GID
SAMLConfigID gid.GID
}
func (s *SAMLService) loadConfigFromRelayState(
ctx context.Context,
relayStateValue string,
) (*coredata.SAMLConfiguration, *coredata.Organization, error) {
if relayStateValue == "" {
return nil, nil, fmt.Errorf("RelayState is required and must contain SAML config ID")
}
samlConfigID, err := gid.ParseGID(relayStateValue)
if err != nil {
return nil, nil, fmt.Errorf("invalid SAML config ID in RelayState: %w", err)
}
var config coredata.SAMLConfiguration
var org coredata.Organization
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := config.LoadByID(ctx, conn, coredata.NewNoScope(), samlConfigID); err != nil {
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if err := org.LoadByID(ctx, conn, coredata.NewNoScope(), config.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return &config, &org, nil
}
func (s *SAMLService) HandleSAMLAssertion(
ctx context.Context,
req *http.Request,
) (*SAMLUserInfo, error) {
samlResponseEncoded := req.FormValue("SAMLResponse")
if samlResponseEncoded == "" {
return nil, fmt.Errorf("missing SAMLResponse in request")
}
relayStateValue := req.FormValue("RelayState")
config, org, err := s.loadConfigFromRelayState(ctx, relayStateValue)
if err != nil {
return nil, err
}
if !config.Enabled {
return nil, ErrSAMLDisabled{OrganizationID: config.OrganizationID}
}
sp, err := s.GetServiceProvider(ctx, config)
if err != nil {
return nil, ErrCannotCreateServiceProvider{Err: err}
}
if req.URL.Scheme == "" {
req.URL.Scheme = "https"
}
if req.URL.Host == "" {
req.URL.Host = req.Host
}
now := time.Now()
var possibleRequestIDs []string
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
requestIDs, err := coredata.LoadValidRequestIDsForOrganization(ctx, conn, config.OrganizationID, now)
if err != nil {
return err
}
possibleRequestIDs = requestIDs
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot load valid request IDs: %w", err)
}
assertion, err := sp.ParseResponse(req, possibleRequestIDs)
if err != nil {
return nil, fmt.Errorf("cannot parse SAML response: %w", err)
}
if err := ValidateAssertion(assertion, s.GetEntityID(), now); err != nil {
return nil, ErrCannotValidateAssertion{Err: err}
}
if assertion.ID != "" {
var expiresAt time.Time
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
expiresAt = assertion.Conditions.NotOnOrAfter
} else {
expiresAt = now.Add(24 * time.Hour)
}
scope := coredata.NewScope(org.TenantID)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := PreventReplayAttack(ctx, tx, scope, assertion.ID, config.OrganizationID, expiresAt); err != nil {
return fmt.Errorf("cannot prevent replay attack: %w", err)
}
return nil
},
)
if err != nil {
var replayAttackErr *coredata.ErrAssertionAlreadyUsed
if errors.As(err, &replayAttackErr) {
return nil, ErrReplayAttackDetected{AssertionID: assertion.ID, Err: replayAttackErr}
}
return nil, fmt.Errorf("cannot prevent replay attack: %w", err)
}
}
email, fullname, samlRole, err := ExtractUserAttributes(
assertion,
config.AttributeEmail,
config.AttributeFirstname,
config.AttributeLastname,
config.AttributeRole,
)
if err != nil {
return nil, ErrCannotExtractUserAttributes{Err: err}
}
if !strings.EqualFold(email.Domain(), config.EmailDomain) {
return nil, fmt.Errorf("email domain mismatch: assertion contains email with domain %s but SAML config is for domain %s", email.Domain(), config.EmailDomain)
}
systemRole := MapSAMLRoleToSystemRole(samlRole)
samlSubject := ""
if assertion.Subject != nil && assertion.Subject.NameID != nil {
samlSubject = assertion.Subject.NameID.Value
}
return &SAMLUserInfo{
Email: email,
FullName: fullname,
Role: systemRole,
SAMLSubject: samlSubject,
OrganizationID: config.OrganizationID,
SAMLConfigID: config.ID,
}, nil
}
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
return fmt.Sprintf("%s/connect/saml/metadata/%s", s.baseURL, organizationID)
}
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
if s.certificate == nil {
return nil, ErrSPCertificateNotConfigured{}
}
return GenerateServiceProviderMetadata(
s.GetEntityID(),
s.GetAcsURL(),
s.certificate,
)
}

View File

@@ -1,103 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/crewjam/saml"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
)
func PreventReplayAttack(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
assertionID string,
organizationID gid.GID,
expiresAt time.Time,
) error {
now := time.Now()
assertion := coredata.SAMLAssertion{
ID: assertionID,
OrganizationID: organizationID,
UsedAt: now,
ExpiresAt: expiresAt,
}
if err := assertion.Insert(ctx, conn, scope); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "auth_saml_assertions_pkey" {
return coredata.ErrAssertionAlreadyUsed{AssertionID: assertionID}
}
return fmt.Errorf("cannot store assertion ID: %w", err)
}
return nil
}
func ValidateAssertion(
assertion *saml.Assertion,
expectedAudience string,
now time.Time,
) error {
const clockSkewTolerance = 5 * time.Minute
if assertion.Conditions != nil && !assertion.Conditions.NotBefore.IsZero() {
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v, tolerance: %v)",
assertion.Conditions.NotBefore, now, clockSkewTolerance)
}
}
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) ||
now.Add(-clockSkewTolerance).Equal(assertion.Conditions.NotOnOrAfter) {
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v, tolerance: %v)",
assertion.Conditions.NotOnOrAfter, now, clockSkewTolerance)
}
}
if assertion.Conditions != nil && len(assertion.Conditions.AudienceRestrictions) > 0 {
audienceValid := false
for _, restriction := range assertion.Conditions.AudienceRestrictions {
if restriction.Audience.Value == expectedAudience {
audienceValid = true
break
}
}
if !audienceValid {
return fmt.Errorf("assertion audience restriction does not match expected audience %q", expectedAudience)
}
}
return nil
}
func CleanupExpiredAssertions(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLAssertions(ctx, conn, time.Now())
}
func CleanupExpiredRequests(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLRequests(ctx, conn, time.Now())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,933 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package authz
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
)
type TenantAccessError struct {
Message string
}
func (e *TenantAccessError) Error() string {
return "not authorized"
}
type PermissionDeniedError struct {
Message string
}
func (e *PermissionDeniedError) Error() string {
return e.Message
}
type (
Service struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
}
TenantAuthzService struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
scope coredata.Scoper
}
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
baseURL string,
tokenSecret string,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
baseURL: baseURL,
tokenSecret: tokenSecret,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
return &TenantAuthzService{
pg: s.pg,
baseURL: s.baseURL,
tokenSecret: s.tokenSecret,
invitationTokenValidity: s.invitationTokenValidity,
scope: coredata.NewScope(tenantID),
}
}
func (s *Service) GetAllUserOrganizations(
ctx context.Context,
userID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserID(ctx, conn, userID); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizationsWithRole(
ctx context.Context,
userID gid.GID,
role coredata.MembershipRole,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserIDWithRole(ctx, conn, userID, role); err != nil {
return fmt.Errorf("cannot load user organizations with role: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetAllOrganizationsForUserAPIKeyId(
ctx context.Context,
userAPIKeyID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserAPIKeyID(ctx, conn, userAPIKeyID); err != nil {
return fmt.Errorf("cannot load user api key organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizations(
ctx context.Context,
userID gid.GID,
cursor *page.Cursor[coredata.OrganizationOrderField],
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadByUserID(ctx, conn, coredata.NewNoScope(), userID, cursor); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
userID gid.GID,
) (*coredata.Invitation, error) {
var acceptedInvitation *coredata.Invitation
scope := coredata.NewScope(invitationID.TenantID())
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if invitation.Email != user.EmailAddress {
return fmt.Errorf("invitation email does not match user email")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot mark invitation as accepted: %w", err)
}
acceptedInvitation = invitation
return nil
},
)
if err != nil {
return nil, err
}
return acceptedInvitation, nil
}
type UserInvitation struct {
ID gid.GID
Email mail.Addr
FullName string
Role coredata.MembershipRole
ExpiresAt time.Time
AcceptedAt *time.Time
CreatedAt time.Time
OrganizationID gid.GID
Organization OrganizationSummary
}
type OrganizationSummary struct {
ID gid.GID
Name string
}
func (s *Service) GetUserPendingInvitations(
ctx context.Context,
email mail.Addr,
) ([]*UserInvitation, error) {
userInvitations := []*UserInvitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
cursor := page.NewCursor(
1000,
nil,
page.Head,
page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
},
)
filter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
invitations := coredata.Invitations{}
if err := invitations.LoadByEmail(ctx, conn, coredata.NewNoScope(), email, cursor, filter); err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
organizationIDs := []gid.GID{}
for _, invitation := range invitations {
organizationIDs = append(organizationIDs, invitation.OrganizationID)
}
organizations := coredata.Organizations{}
if err := organizations.BatchLoadByID(ctx, conn, coredata.NewNoScope(), organizationIDs); err != nil {
return fmt.Errorf("cannot load organizations: %w", err)
}
for _, invitation := range invitations {
userInvitation := &UserInvitation{
ID: invitation.ID,
Email: invitation.Email,
FullName: invitation.FullName,
Role: invitation.Role,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
OrganizationID: invitation.OrganizationID,
}
for _, org := range organizations {
if org.ID == invitation.OrganizationID {
userInvitation.Organization = OrganizationSummary{
ID: org.ID,
Name: org.Name,
}
}
}
userInvitations = append(userInvitations, userInvitation)
}
return nil
},
)
if err != nil {
return nil, err
}
return userInvitations, nil
}
func (s *TenantAuthzService) GetOrganizationByInvitationID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Organization, error) {
var organization coredata.Organization
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitation coredata.Invitation
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := organization.LoadByID(ctx, conn, s.scope, invitation.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &organization, nil
}
func (s *TenantAuthzService) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role coredata.MembershipRole,
) error {
now := time.Now()
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor, filter); err != nil {
return fmt.Errorf("cannot load organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *TenantAuthzService) CountOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
filter *coredata.InvitationFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var invitations coredata.Invitations
count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID, filter)
if err != nil {
return fmt.Errorf("cannot count organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *TenantAuthzService) GetInvitationByID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Invitation, error) {
invitation := &coredata.Invitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) DeleteInvitation(
ctx context.Context,
invitationID gid.GID,
) error {
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := invitation.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot delete invitation: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetMembershipByUserAndOrganizationID(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var memberships coredata.Memberships
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := memberships.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("cannot load organization memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *TenantAuthzService) CountOrganizationMemberships(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var memberships coredata.Memberships
var err error
count, err = memberships.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) CountOrganizationUsers(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var users coredata.Users
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
if err != nil {
return fmt.Errorf("cannot count organization users: %w", err)
}
return nil
},
)
if err != nil {
return 0, fmt.Errorf("cannot count users: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (coredata.MembershipRole, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
return membership.Role, nil
}
func (s *TenantAuthzService) RemoveMemberFromOrganization(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
) error {
membership := &coredata.Membership{}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
if err := membership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) UpdateMembershipRole(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
newRole coredata.MembershipRole,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
// If the new role cannot create API keys, delete all related API key memberships
if newRole != coredata.MembershipRoleOwner {
var apiKeyMemberships coredata.UserAPIKeyMemberships
if err := apiKeyMemberships.LoadByMembershipID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load api key memberships: %w", err)
}
for _, apiKeyMembership := range apiKeyMemberships {
if err := apiKeyMembership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete api key membership: %w", err)
}
}
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) InviteUserToOrganization(
ctx context.Context,
organizationID gid.GID,
emailAddress mail.Addr,
fullName string,
role coredata.MembershipRole,
) (*coredata.Invitation, error) {
var invitation *coredata.Invitation
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
userExists := true
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var userNotFound *coredata.ErrUserNotFound
if errors.As(err, &userNotFound) {
userExists = false
} else {
return fmt.Errorf("cannot check if user exists: %w", err)
}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
invitationID := gid.New(s.scope.GetTenantID(), coredata.InvitationEntityType)
now := time.Now()
invitation = &coredata.Invitation{
ID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
var err error
var invitationURL string
var recipientName string
if userExists {
recipientName = user.FullName
invitationURL = s.baseURL + "/"
} else {
recipientName = fullName
invitationData := coredata.InvitationData{
InvitationID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
invitationData,
)
if err != nil {
return fmt.Errorf("cannot generate invitation token: %w", err)
}
invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName))
}
subject, textBody, htmlBody, err := emails.RenderInvitation(
s.baseURL,
recipientName,
organization.Name,
invitationURL,
)
if err != nil {
return fmt.Errorf("cannot render invitation email: %w", err)
}
email := coredata.NewEmail(
fullName,
emailAddress,
subject,
textBody,
htmlBody,
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := invitation.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) EnsureSAMLMembership(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
role *coredata.MembershipRole,
) error {
now := time.Now()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var membership coredata.Membership
err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, organizationID)
if err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); !ok {
return fmt.Errorf("cannot load membership: %w", err)
}
membershipRole := coredata.MembershipRoleViewer
if role != nil {
membershipRole = *role
}
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership = coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: organizationID,
Role: membershipRole,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
return nil
}
if role != nil && membership.Role != *role {
membership.Role = *role
membership.UpdatedAt = now
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
}
return nil
},
)
}
func (s *TenantAuthzService) Authorize(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
action Action,
) error {
requiredRoles := GetPermissionsForAction(entityGID.EntityType(), action)
if requiredRoles == nil {
entityModel, _ := coredata.EntityModel(entityGID.EntityType())
return &PermissionDeniedError{
Message: fmt.Sprintf("no permissions defined for action %s on entity %s", action, entityModel),
}
}
role, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if !slices.Contains(requiredRoles, role) {
return &PermissionDeniedError{
Message: fmt.Sprintf("role %s not authorized for action %s, requires one of %v", role, action, requiredRoles),
}
}
return nil
}
func (s *TenantAuthzService) CanAssignRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
targetRole coredata.MembershipRole,
) error {
currentRole, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if currentRole == RoleOwner || currentRole == RoleFull {
return nil
}
if currentRole == RoleAdmin {
if targetRole == coredata.MembershipRoleOwner {
return &PermissionDeniedError{Message: "admin users cannot assign owner role"}
}
return nil
}
return &PermissionDeniedError{Message: fmt.Sprintf("role %s cannot assign roles", currentRole)}
}
func (s *TenantAuthzService) GetUserOrAPIKeyRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
) (Role, error) {
var role Role
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if user != nil {
membership := &coredata.Membership{}
if err := membership.LoadRoleByUserAndEntityID(ctx, conn, s.scope, user.ID, entityGID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
role = Role(membership.Role.String())
return nil
}
if apiKey != nil {
apiKeyMembership := &coredata.UserAPIKeyMembership{}
if err := apiKeyMembership.LoadRoleByAPIKeyAndEntityID(ctx, conn, s.scope, apiKey.ID, entityGID); err != nil {
return fmt.Errorf("cannot get API key role: %w", err)
}
role = Role(apiKeyMembership.Role.String())
return nil
}
return fmt.Errorf("no user or API key provided")
},
)
if err != nil {
return "", err
}
return role, nil
}

View File

@@ -109,10 +109,10 @@ func (b *BaseURL) Port() string {
// URLBuilder provides a fluent interface for building URLs.
type URLBuilder struct {
base *BaseURL
path string
query url.Values
err error
base *BaseURL
path string
query url.Values
err error
}
// WithPath returns a URLBuilder with the specified path.
@@ -224,3 +224,12 @@ func (b *BaseURL) MarshalText() ([]byte, error) {
}
return []byte(b.raw), nil
}
func (b *URLBuilder) URL() url.URL {
return url.URL{
Scheme: b.base.Scheme(),
Host: b.base.Host(),
Path: b.path,
RawQuery: b.query.Encode(),
}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
// Package bearertoken parses Bearer tokens according to RFC 6750.
//
// The grammar is defined as:
//
// b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
// credentials = "Bearer" 1*SP b64token
package bearertoken
import (
"errors"
"strings"
)
var (
// ErrInvalidCredentials is returned when the credentials string is malformed.
ErrInvalidCredentials = errors.New("invalid bearer credentials")
// ErrMissingToken is returned when the token part is empty.
ErrMissingToken = errors.New("missing bearer token")
// ErrInvalidToken is returned when the token contains invalid characters.
ErrInvalidToken = errors.New("invalid bearer token")
)
const (
scheme = "Bearer"
)
// Parse extracts the b64token from a Bearer credentials string.
// The input must follow the format: "Bearer" 1*SP b64token
func Parse(credentials string) (string, error) {
if len(credentials) <= len(scheme) {
return "", ErrInvalidCredentials
}
if !strings.EqualFold(credentials[:len(scheme)], scheme) {
return "", ErrInvalidCredentials
}
rest := credentials[len(scheme):]
if len(rest) == 0 || rest[0] != ' ' {
return "", ErrInvalidCredentials
}
// Skip all spaces (1*SP)
token := strings.TrimLeft(rest, " ")
if token == "" {
return "", ErrMissingToken
}
if !isValidToken(token) {
return "", ErrInvalidToken
}
return token, nil
}
// isValidToken checks if the given string is a valid b64token.
// A valid b64token consists of 1 or more characters from the set
// [A-Za-z0-9-._~+/] followed by zero or more '=' characters.
func isValidToken(token string) bool {
if len(token) == 0 {
return false
}
// Find where the padding starts (if any)
paddingStart := strings.IndexByte(token, '=')
if paddingStart == -1 {
paddingStart = len(token)
}
// Must have at least one non-padding character
if paddingStart == 0 {
return false
}
// Validate the base part (before padding)
for i := 0; i < paddingStart; i++ {
if !isB64Char(token[i]) {
return false
}
}
// Validate padding (only '=' allowed after first '=')
for i := paddingStart; i < len(token); i++ {
if token[i] != '=' {
return false
}
}
return true
}
// isB64Char returns true if c is a valid b64token character (excluding padding).
// Valid characters: ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/"
func isB64Char(c byte) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~' ||
c == '+' ||
c == '/'
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package bearertoken
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
credentials string
wantToken string
wantErr error
}{
// Valid credentials
{
name: "valid simple token",
credentials: "Bearer abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "valid uppercase token",
credentials: "Bearer ABCXYZ",
wantToken: "ABCXYZ",
wantErr: nil,
},
{
name: "valid digits only token",
credentials: "Bearer 0123456789",
wantToken: "0123456789",
wantErr: nil,
},
{
name: "valid base64 token with single padding",
credentials: "Bearer dXNlcm5hbWU6cGFzc3dvcmQ=",
wantToken: "dXNlcm5hbWU6cGFzc3dvcmQ=",
wantErr: nil,
},
{
name: "valid base64 token with double padding",
credentials: "Bearer YWJj==",
wantToken: "YWJj==",
wantErr: nil,
},
{
name: "valid token with all special chars",
credentials: "Bearer abc-._~+/123",
wantToken: "abc-._~+/123",
wantErr: nil,
},
{
name: "valid token with hyphen",
credentials: "Bearer abc-def",
wantToken: "abc-def",
wantErr: nil,
},
{
name: "valid token with dot",
credentials: "Bearer abc.def",
wantToken: "abc.def",
wantErr: nil,
},
{
name: "valid token with underscore",
credentials: "Bearer abc_def",
wantToken: "abc_def",
wantErr: nil,
},
{
name: "valid token with tilde",
credentials: "Bearer abc~def",
wantToken: "abc~def",
wantErr: nil,
},
{
name: "valid token with plus",
credentials: "Bearer abc+def",
wantToken: "abc+def",
wantErr: nil,
},
{
name: "valid token with slash",
credentials: "Bearer abc/def",
wantToken: "abc/def",
wantErr: nil,
},
{
name: "valid token with multiple spaces after scheme",
credentials: "Bearer token123",
wantToken: "token123",
wantErr: nil,
},
{
name: "valid jwt-like token",
credentials: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
wantToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
wantErr: nil,
},
{
name: "valid single char token",
credentials: "Bearer a",
wantToken: "a",
wantErr: nil,
},
// Case insensitive scheme
{
name: "lowercase scheme",
credentials: "bearer abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "uppercase scheme",
credentials: "BEARER abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "mixed case scheme",
credentials: "BeArEr abc123",
wantToken: "abc123",
wantErr: nil,
},
// Invalid credentials (scheme errors)
{
name: "empty string",
credentials: "",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "only scheme without space",
credentials: "Bearer",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "scheme without space before token",
credentials: "Bearerabc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "wrong scheme Basic",
credentials: "Basic abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "wrong scheme Digest",
credentials: "Digest abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "partial scheme",
credentials: "Bear abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "scheme with tab instead of space",
credentials: "Bearer\tabc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
// Missing token
{
name: "missing token after single space",
credentials: "Bearer ",
wantToken: "",
wantErr: ErrMissingToken,
},
{
name: "missing token after multiple spaces",
credentials: "Bearer ",
wantToken: "",
wantErr: ErrMissingToken,
},
// Invalid token characters
{
name: "invalid char @",
credentials: "Bearer abc@123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char #",
credentials: "Bearer abc#123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char !",
credentials: "Bearer abc!123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char ?",
credentials: "Bearer abc?123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char *",
credentials: "Bearer abc*123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char space in token",
credentials: "Bearer abc 123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char tab in token",
credentials: "Bearer abc\t123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char newline in token",
credentials: "Bearer abc\n123",
wantToken: "",
wantErr: ErrInvalidToken,
},
// Invalid padding
{
name: "token starting with equals",
credentials: "Bearer =abc",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token with equals in middle",
credentials: "Bearer abc=def",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token only padding",
credentials: "Bearer ==",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token with char after padding",
credentials: "Bearer abc==def",
wantToken: "",
wantErr: ErrInvalidToken,
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
gotToken, gotErr := Parse(tt.credentials)
if tt.wantErr != nil {
require.ErrorIs(t, gotErr, tt.wantErr)
assert.Empty(t, gotToken)
} else {
require.NoError(t, gotErr)
assert.Equal(t, tt.wantToken, gotToken)
}
},
)
}
}

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Assets []*Asset
ErrAssetNotFound struct {
Identifier string
}
ErrAssetAlreadyExists struct {
message string
}
)
func (e ErrAssetNotFound) Error() string {
return fmt.Sprintf("asset not found: %q", e.Identifier)
}
func (e ErrAssetAlreadyExists) Error() string {
return e.message
}
func (a *Asset) CursorKey(field AssetOrderField) page.CursorKey {
switch field {
case AssetOrderFieldCreatedAt:
@@ -112,7 +96,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: assetID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)
@@ -162,7 +146,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: a.OwnerID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Audits []*Audit
ErrAuditNotFound struct {
Identifier string
}
ErrAuditAlreadyExists struct {
message string
}
)
func (e ErrAuditNotFound) Error() string {
return fmt.Sprintf("audit not found: %q", e.Identifier)
}
func (e ErrAuditAlreadyExists) Error() string {
return e.message
}
func (a *Audit) CursorKey(field AuditOrderField) page.CursorKey {
switch field {
case AuditOrderFieldCreatedAt:
@@ -116,7 +100,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: auditID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)
@@ -530,7 +514,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)

View File

@@ -44,24 +44,8 @@ type (
}
Controls []*Control
ErrControlNotFound struct {
Identifier string
}
ErrControlAlreadyExists struct {
message string
}
)
func (e ErrControlNotFound) Error() string {
return fmt.Sprintf("control not found: %q", e.Identifier)
}
func (e ErrControlAlreadyExists) Error() string {
return e.message
}
func (c Control) CursorKey(orderBy ControlOrderField) page.CursorKey {
switch orderBy {
case ControlOrderFieldCreatedAt:
@@ -661,7 +645,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: fmt.Sprintf("%s:%s", frameworkID, sectionTitle)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -710,7 +694,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: controlID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -778,9 +762,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with framework_id %s and section_title %q already exists", c.FrameworkID, c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert control: %w", err)
@@ -848,9 +830,7 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with section_title %q already exists", c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update control: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -37,17 +37,8 @@ type (
}
ControlDocuments []*ControlDocument
ErrControlDocumentMappingAlreadyExists struct {
ControlID gid.GID
DocumentID gid.GID
}
)
func (e ErrControlDocumentMappingAlreadyExists) Error() string {
return fmt.Sprintf("control %s is already mapped to document %s", e.ControlID, e.DocumentID)
}
func (cp ControlDocument) Insert(
ctx context.Context,
conn pg.Conn,
@@ -84,10 +75,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_policies_pkey" {
return &ErrControlDocumentMappingAlreadyExists{
ControlID: cp.ControlID,
DocumentID: cp.DocumentID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -52,24 +52,8 @@ type (
}
CustomDomains []*CustomDomain
ErrCustomDomainNotFound struct {
Identifier string
}
ErrCustomDomainAlreadyExists struct {
message string
}
)
func (e ErrCustomDomainNotFound) Error() string {
return fmt.Sprintf("custom domain not found: %q", e.Identifier)
}
func (e ErrCustomDomainAlreadyExists) Error() string {
return e.message
}
func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain {
now := time.Now()
return &CustomDomain{
@@ -385,9 +369,7 @@ INSERT INTO custom_domains (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "custom_domains_domain_key" {
return &ErrCustomDomainAlreadyExists{
message: fmt.Sprintf("custom domain with domain %q already exists", cd.Domain),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert custom domain: %w", err)

View File

@@ -43,24 +43,8 @@ type (
}
Documents []*Document
ErrDocumentNotFound struct {
Identifier string
}
ErrDocumentAlreadyExists struct {
message string
}
)
func (e ErrDocumentNotFound) Error() string {
return fmt.Sprintf("document not found: %q", e.Identifier)
}
func (e ErrDocumentAlreadyExists) Error() string {
return e.message
}
func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
switch orderBy {
case DocumentOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)
@@ -168,7 +152,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)

View File

@@ -46,32 +46,8 @@ type (
}
DocumentVersions []*DocumentVersion
ErrDocumentVersionNotFound struct {
Identifier string
}
ErrDocumentVersionAlreadyExists struct {
message string
}
ErrDocumentVersionNoChanges struct {
Message string
}
)
func (e ErrDocumentVersionNotFound) Error() string {
return fmt.Sprintf("document version not found: %q", e.Identifier)
}
func (e ErrDocumentVersionAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionNoChanges) Error() string {
return e.Message
}
func (p *DocumentVersions) LoadByDocumentID(
ctx context.Context,
conn pg.Conn,
@@ -245,15 +221,8 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document version with document_id %s and version_number %d already exists", p.DocumentID, p.VersionNumber),
}
}
if pgErr.ConstraintName == "document_one_draft_version_idx" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document %s already has a draft version", p.DocumentID),
}
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
return ErrResourceAlreadyExists
}
}
}

View File

@@ -50,30 +50,7 @@ type (
}
DocumentVersionSignaturesWithPeople []*DocumentVersionSignatureWithPeople
ErrDocumentVersionSignatureNotFound struct {
Identifier string
}
ErrDocumentVersionSignatureAlreadyExists struct {
message string
}
ErrDocumentVersionSignatureAlreadySigned struct{}
)
func (e ErrDocumentVersionSignatureNotFound) Error() string {
return fmt.Sprintf("document version signature not found: %q", e.Identifier)
}
func (e ErrDocumentVersionSignatureAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
return "document version already signed"
}
func (pvs DocumentVersionSignature) CursorKey(orderBy DocumentVersionSignatureOrderField) page.CursorKey {
switch orderBy {
case DocumentVersionSignatureOrderFieldCreatedAt:
@@ -225,9 +202,7 @@ INSERT INTO document_version_signatures (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "policy_version_signatures_policy_version_id_signed_by_key" {
return &ErrDocumentVersionSignatureAlreadyExists{
message: fmt.Sprintf("document version signature with document_version_id %s and signed_by %s already exists", pvs.DocumentVersionID, pvs.SignedBy),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert document version signature: %w", err)

11
pkg/coredata/errors.go Normal file
View File

@@ -0,0 +1,11 @@
package coredata
import (
"errors"
)
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
)

View File

@@ -45,24 +45,8 @@ type (
}
Evidences []*Evidence
ErrEvidenceNotFound struct {
Identifier string
}
ErrEvidenceAlreadyExists struct {
message string
}
)
func (e ErrEvidenceNotFound) Error() string {
return fmt.Sprintf("evidence not found: %q", e.Identifier)
}
func (e ErrEvidenceAlreadyExists) Error() string {
return e.message
}
func (e Evidence) CursorKey(orderBy EvidenceOrderField) page.CursorKey {
switch orderBy {
case EvidenceOrderFieldCreatedAt:
@@ -192,9 +176,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "evidences_reference_id_key" {
return &ErrEvidenceAlreadyExists{
message: fmt.Sprintf("evidence with task_id %s and reference_id %q already exists", e.TaskID, e.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert evidence: %w", err)

View File

@@ -0,0 +1,11 @@
package coredata
type (
ExpireReason string
)
const (
ExpireReasonIdleTimeout ExpireReason = "idle_timeout"
ExpireReasonRevoked ExpireReason = "revoked"
ExpireReasonClosed ExpireReason = "closed"
)

View File

@@ -42,24 +42,8 @@ type (
}
Files []*File
ErrFileNotFound struct {
Identifier string
}
ErrFileAlreadyExists struct {
message string
}
)
func (e ErrFileNotFound) Error() string {
return fmt.Sprintf("file not found: %q", e.Identifier)
}
func (e ErrFileAlreadyExists) Error() string {
return e.message
}
func (f *File) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -100,7 +84,7 @@ LIMIT 1;
file, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[File])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFileNotFound{Identifier: fileID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect file: %w", err)
@@ -165,9 +149,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "files_file_key_key" {
return &ErrFileAlreadyExists{
message: fmt.Sprintf("file with file_key %q already exists", f.FileKey),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert file: %w", err)

View File

@@ -42,33 +42,8 @@ type (
}
Frameworks []*Framework
ErrFrameworkNotFound struct {
Identifier string
}
ErrFrameworkAlreadyExists struct {
message string
}
ErrFrameworkReferenceIDAlreadyExists struct {
ReferenceID string
OrganizationID gid.GID
}
)
func (e ErrFrameworkNotFound) Error() string {
return fmt.Sprintf("framework not found: %q", e.Identifier)
}
func (e ErrFrameworkAlreadyExists) Error() string {
return e.message
}
func (e ErrFrameworkReferenceIDAlreadyExists) Error() string {
return fmt.Sprintf("framework with reference ID %q already exists for organization %s", e.ReferenceID, e.OrganizationID)
}
func (f *Framework) CursorKey(orderBy FrameworkOrderField) page.CursorKey {
switch orderBy {
case FrameworkOrderFieldCreatedAt:
@@ -192,7 +167,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: referenceID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -240,7 +215,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: frameworkID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -302,10 +277,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "frameworks_org_ref_unique" {
return &ErrFrameworkReferenceIDAlreadyExists{
ReferenceID: f.ReferenceID,
OrganizationID: f.OrganizationID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -42,24 +42,8 @@ type (
}
Invitations []*Invitation
InvitationData struct {
InvitationID gid.GID `json:"invitation_id"`
OrganizationID gid.GID `json:"organization_id"`
Email mail.Addr `json:"email"`
FullName string `json:"full_name"`
Role MembershipRole `json:"role"`
}
ErrInvitationNotFound struct {
ID string
}
)
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation not found: %s", e.ID)
}
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
@@ -83,7 +67,7 @@ func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (i *Invitation) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_invitations (
@@ -170,8 +154,9 @@ WHERE
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvitationNotFound{ID: id.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect invitation: %w", err)
}
@@ -204,25 +189,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper, invitationID gid.GID) error {
query := `
DELETE FROM
authz_invitations
WHERE
id = @id
AND %s
%s
AND id = @invitation_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": i.ID,
"invitation_id": invitationID,
}
maps.Copy(args, scope.SQLArguments())
@@ -232,13 +217,13 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitations) LoadByEmail(
func (i *Invitations) LoadByIdentityID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
@@ -386,7 +371,7 @@ WHERE
func (i *Invitations) CountByEmail(
ctx context.Context,
conn pg.Conn,
email string,
email mail.Addr,
filter *InvitationFilter,
) (int, error) {
q := `

View File

@@ -43,24 +43,8 @@ type (
}
Measures []*Measure
ErrMeasureNotFound struct {
Identifier string
}
ErrMeasureAlreadyExists struct {
message string
}
)
func (e ErrMeasureNotFound) Error() string {
return fmt.Sprintf("measure not found: %q", e.Identifier)
}
func (e ErrMeasureAlreadyExists) Error() string {
return e.message
}
func (m Measure) CursorKey(orderBy MeasureOrderField) page.CursorKey {
switch orderBy {
case MeasureOrderFieldCreatedAt:
@@ -414,7 +398,7 @@ LIMIT 1;
measure, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Measure])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeasureNotFound{Identifier: measureID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect measures: %w", err)
@@ -552,9 +536,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "mitigations_org_ref_unique" {
return &ErrMeasureAlreadyExists{
message: fmt.Sprintf("measure with organization_id %s and reference_id %q already exists", m.OrganizationID, m.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert measure: %w", err)

View File

@@ -39,24 +39,8 @@ type (
}
Meetings []*Meeting
ErrMeetingNotFound struct {
Identifier string
}
ErrMeetingAlreadyExists struct {
message string
}
)
func (e ErrMeetingNotFound) Error() string {
return fmt.Sprintf("meeting not found: %s", e.Identifier)
}
func (e ErrMeetingAlreadyExists) Error() string {
return e.message
}
func (m Meeting) CursorKey(orderBy MeetingOrderField) page.CursorKey {
switch orderBy {
case MeetingOrderFieldCreatedAt:
@@ -106,7 +90,7 @@ LIMIT 1;
meeting, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Meeting])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeetingNotFound{Identifier: meetingID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect meeting: %w", err)
@@ -271,7 +255,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -300,7 +284,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,26 +43,8 @@ type (
}
Memberships []*Membership
ErrMembershipNotFound struct {
UserID gid.GID
OrgID gid.GID
}
ErrMembershipAlreadyExists struct {
UserID gid.GID
OrgID gid.GID
}
)
func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership not found for user %s in organization %s", e.UserID, e.OrgID)
}
func (e ErrMembershipAlreadyExists) Error() string {
return fmt.Sprintf("membership already exists for user %s in organization %s", e.UserID, e.OrgID)
}
func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
switch orderBy {
case MembershipOrderFieldFullName:
@@ -78,7 +60,46 @@ func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (m *Membership) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) LoadByUserInOrganization(ctx context.Context, conn pg.Conn, userID gid.GID, organizationID gid.GID) error {
q := `
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
user_id = @user_id
AND organization_id = @organization_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": organizationID,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_memberships (
@@ -115,8 +136,9 @@ VALUES (
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot create membership: %w", err)
}
@@ -178,8 +200,9 @@ JOIN
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: gid.GID{}, OrgID: gid.GID{}}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -243,7 +266,7 @@ LIMIT 1;
defer rows.Close()
if !rows.Next() {
return &ErrMembershipNotFound{UserID: userID, OrgID: entityID}
return ErrResourceNotFound
}
var membership Membership
@@ -269,9 +292,9 @@ func (m *Membership) LoadByUserAndOrg(
conn pg.Conn,
scope Scoper,
userID gid.GID,
orgID gid.GID,
organizationID gid.GID,
) error {
query := `
q := `
WITH mbr AS (
SELECT
am.id,
@@ -302,20 +325,15 @@ JOIN
users u ON mbr.user_id = u.id
`
// Build scope fragment with table alias
scopeFragment := scope.SQLFragment()
// Replace column references with table-qualified versions
scopeFragment = strings.ReplaceAll(scopeFragment, "tenant_id =", "am.tenant_id =")
query = fmt.Sprintf(query, scopeFragment)
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": orgID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
@@ -323,8 +341,9 @@ JOIN
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: userID, OrgID: orgID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -359,25 +378,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
}
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper, membershipID gid.GID) error {
query := `
DELETE FROM
authz_memberships
WHERE
id = @id
AND %s
%s
AND id = @membership_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": m.ID,
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
@@ -387,7 +406,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
@@ -398,6 +417,7 @@ func (m *Memberships) LoadByUserID(
conn pg.Conn,
scope Scoper,
userID gid.GID,
cursor *page.Cursor[MembershipOrderField],
) error {
query := `
WITH mbr AS (
@@ -552,3 +572,29 @@ WHERE
}
return count, nil
}
func (m *Memberships) CountByUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) (int, error) {
query := `
SELECT
COUNT(*)
FROM
authz_memberships
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
}
row := conn.QueryRow(ctx, query, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,9 @@
CREATE TYPE session_expire_reason AS ENUM (
'idle_timeout',
'revoked',
'closed'
);
ALTER TABLE sessions ADD COLUMN expire_reason session_expire_reason;
UPDATE sessions SET expire_reason = 'idle_timeout' WHERE expired_at < NOW();

View File

@@ -0,0 +1,2 @@
ALTER TABLE sessions ADD COLUMN user_agent TEXT DEFAULT 'SESSION_CREATED_BEFORE_USER_AGENT_COLUMN_ADDED';
ALTER TABLE sessions ADD COLUMN ip_address INET DEFAULT '::1';

View File

@@ -0,0 +1,8 @@
ALTER TABLE sessions ADD COLUMN tenant_id TEXT;
ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id);
ALTER TABLE sessions ADD CONSTRAINT session_tenant_check CHECK (
(parent_session_id IS NULL AND tenant_id IS NULL) OR
(parent_session_id IS NOT NULL AND tenant_id IS NOT NULL)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE auth_user_api_keys ADD COLUMN expire_reason TEXT;

View File

@@ -44,24 +44,8 @@ type (
}
Organizations []*Organization
ErrOrganizationNotFound struct {
Identifier string
}
ErrOrganizationAlreadyExists struct {
message string
}
)
func (e ErrOrganizationNotFound) Error() string {
return fmt.Sprintf("organization not found: %q", e.Identifier)
}
func (e ErrOrganizationAlreadyExists) Error() string {
return e.message
}
func (o Organization) CursorKey(orderBy OrganizationOrderField) page.CursorKey {
switch orderBy {
case OrganizationOrderFieldName:
@@ -116,7 +100,7 @@ LIMIT 1;
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)
@@ -442,19 +426,14 @@ WHERE
func (o *Organization) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
q := `
DELETE FROM organizations
WHERE
%s
AND id = @id
WHERE id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": o.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
@@ -505,7 +484,7 @@ LIMIT 1
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: customDomainID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)

View File

@@ -33,16 +33,8 @@ type (
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
ErrOrganizationContextNotFound struct {
Identifier string
}
)
func (e ErrOrganizationContextNotFound) Error() string {
return fmt.Sprintf("organization context not found: %q", e.Identifier)
}
func (oc *OrganizationContext) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
@@ -76,7 +68,7 @@ LIMIT 1;
orgContext, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OrganizationContext])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationContextNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization context: %w", err)
@@ -155,7 +147,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return &ErrOrganizationContextNotFound{Identifier: oc.OrganizationID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -45,32 +45,8 @@ type (
}
Peoples []*People
ErrPeopleNotFound struct {
Identifier string
}
ErrPeopleAlreadyExists struct {
message string
}
ErrPeopleReferenced struct {
message string
}
)
func (e ErrPeopleNotFound) Error() string {
return fmt.Sprintf("people not found: %s", e.Identifier)
}
func (e ErrPeopleAlreadyExists) Error() string {
return e.message
}
func (e ErrPeopleReferenced) Error() string {
return e.message
}
func (p People) CursorKey(orderBy PeopleOrderField) page.CursorKey {
switch orderBy {
case PeopleOrderFieldCreatedAt:
@@ -124,7 +100,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: peopleID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -175,7 +151,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -231,7 +207,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -362,9 +338,7 @@ DELETE FROM peoples WHERE %s AND id = @people_id
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23503" {
return &ErrPeopleReferenced{
message: fmt.Sprintf("person with id %s cannot be deleted because it is referenced by other records", p.ID),
}
return ErrResourceInUse
}
}
return fmt.Errorf("cannot delete person: %w", err)

View File

@@ -79,7 +79,7 @@ LIMIT 1;
report, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Report])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect report: %w", err)

View File

@@ -57,24 +57,8 @@ type (
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error
}
ErrRiskNotFound struct {
Identifier string
}
ErrRiskAlreadyExists struct {
message string
}
)
func (e ErrRiskNotFound) Error() string {
return fmt.Sprintf("risk not found: %q", e.Identifier)
}
func (e ErrRiskAlreadyExists) Error() string {
return e.message
}
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
switch orderBy {
case RiskOrderFieldCreatedAt:
@@ -392,7 +376,7 @@ LIMIT 1;
risk, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Risk])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrRiskNotFound{Identifier: riskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect risk: %w", err)

View File

@@ -16,12 +16,14 @@ package coredata
import (
"context"
"errors"
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLAssertion struct {
@@ -31,27 +33,17 @@ type SAMLAssertion struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrAssertionAlreadyUsed struct {
AssertionID string
}
func (e ErrAssertionAlreadyUsed) Error() string {
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
}
func (s *SAMLAssertion) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
INSERT INTO auth_saml_assertions (id, organization_id, used_at, expires_at)
VALUES (@id, @organization_id, @used_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"used_at": s.UsedAt,
"expires_at": s.ExpiresAt,
@@ -59,6 +51,11 @@ VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
_, err := conn.Exec(ctx, query, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "auth_saml_assertions_pkey" {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot insert saml_assertion: %w", err)
}

View File

@@ -16,6 +16,8 @@ package coredata
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"maps"
"time"
@@ -23,28 +25,55 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
Enabled bool `db:"enabled"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_role"`
AutoSignupEnabled bool `db:"auto_signup_enabled"`
DomainVerified bool `db:"domain_verified"`
DomainVerificationToken *string `db:"domain_verification_token"`
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
type (
SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_role"`
AutoSignupEnabled bool `db:"auto_signup_enabled"`
DomainVerified bool `db:"domain_verified"`
DomainVerificationToken *string `db:"domain_verification_token"`
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
SAMLConfigurations []*SAMLConfiguration
)
func (s *SAMLConfiguration) CursorKey(orderBy SAMLConfigurationOrderField) page.CursorKey {
switch orderBy {
case SAMLConfigurationOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *SAMLConfiguration) GetIdPCertificate() (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(s.IdPCertificate))
if block == nil {
return nil, fmt.Errorf("cannot decode PEM block from IdP certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return cert, nil
}
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
@@ -154,6 +183,10 @@ LIMIT 1;
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
if err == pgx.ErrNoRows {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect saml_configuration: %w", err)
}
@@ -173,7 +206,6 @@ INSERT INTO auth_saml_configurations (
tenant_id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -194,7 +226,6 @@ INSERT INTO auth_saml_configurations (
@tenant_id,
@organization_id,
@email_domain,
@enabled,
@enforcement_policy,
@idp_entity_id,
@idp_sso_url,
@@ -218,7 +249,6 @@ INSERT INTO auth_saml_configurations (
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"email_domain": s.EmailDomain,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -252,7 +282,6 @@ func (s *SAMLConfiguration) Update(
q := `
UPDATE auth_saml_configurations
SET
enabled = @enabled,
enforcement_policy = @enforcement_policy,
idp_entity_id = @idp_entity_id,
idp_sso_url = @idp_sso_url,
@@ -276,7 +305,6 @@ WHERE
args := pgx.StrictNamedArgs{
"id": s.ID,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -328,18 +356,17 @@ WHERE
return nil
}
func LoadSAMLConfigurationsByOrganizationID(
func (s *SAMLConfigurations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]*SAMLConfiguration, error) {
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -370,20 +397,17 @@ ORDER BY email_domain ASC;
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
samlConfigurations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[SAMLConfiguration])
if err != nil {
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
return fmt.Errorf("cannot collect saml_configurations: %w", err)
}
result := make([]*SAMLConfiguration, len(configs))
for i := range configs {
result[i] = &configs[i]
}
*s = samlConfigurations
return result, nil
return nil
}
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
@@ -398,7 +422,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -458,7 +481,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -503,3 +525,38 @@ WHERE
return result, nil
}
func (s *SAMLConfigurations) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_saml_configurations
WHERE
%s
AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
var count int
err = rows.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
type (
SAMLConfigurationOrderField string
)
const (
SAMLConfigurationOrderFieldCreatedAt SAMLConfigurationOrderField = "CREATED_AT"
)
func (p SAMLConfigurationOrderField) Column() string {
return string(p)
}
func (p SAMLConfigurationOrderField) String() string {
return string(p)
}
func (p SAMLConfigurationOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SAMLConfigurationOrderField) UnmarshalText(text []byte) error {
*p = SAMLConfigurationOrderField(text)
return nil
}

View File

@@ -19,9 +19,9 @@ import (
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLRequest struct {
@@ -31,37 +31,18 @@ type SAMLRequest struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrSAMLRequestNotFound struct {
RequestID string
}
func (e ErrSAMLRequestNotFound) Error() string {
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
}
type ErrSAMLRequestExpired struct {
RequestID string
ExpiresAt time.Time
}
func (e ErrSAMLRequestExpired) Error() string {
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
}
func (s *SAMLRequest) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
INSERT INTO auth_saml_requests (id, organization_id, created_at, expires_at)
VALUES (@id, @organization_id, @created_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"organization_id": s.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
}
@@ -99,8 +80,9 @@ LIMIT 1
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
if err == pgx.ErrNoRows {
return ErrSAMLRequestNotFound{RequestID: requestID}
return ErrResourceNotFound
}
if err != nil {
return fmt.Errorf("cannot collect saml_request: %w", err)
}

View File

@@ -17,8 +17,8 @@ package coredata
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -62,6 +62,10 @@ func NewScope(tenantID gid.TenantID) *Scope {
}
}
func NewScopeFromObjectID(objectID gid.GID) *Scope {
return NewScope(objectID.TenantID())
}
func (s *Scope) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{
"tenant_id": s.tenantID,

View File

@@ -18,26 +18,35 @@ import (
"context"
"errors"
"fmt"
"maps"
"net"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Session struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Data SessionData `db:"data"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
TenantID *gid.TenantID `db:"tenant_id"`
ParentSessionID *gid.GID `db:"parent_session_id"`
Data SessionData `db:"data"`
UserAgent string `db:"user_agent"`
IPAddress net.IP `db:"ip_address"`
ExpireReason *ExpireReason `db:"expire_reason"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Sessions []*Session
SessionData struct {
PasswordAuthenticated bool `json:"password_authenticated"`
PasswordAuthenticated bool `json:"password_authenticated"`
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
}
@@ -46,33 +55,39 @@ type (
SAMLConfigID gid.GID `json:"saml_config_id"`
SAMLSubject string `json:"saml_subject"`
}
ErrSessionNotFound struct {
Identifier string
}
ErrSessionAlreadyExists struct {
message string
}
)
func (e ErrSessionNotFound) Error() string {
return fmt.Sprintf("session not found: %q", e.Identifier)
}
func (e ErrSessionAlreadyExists) Error() string {
return e.message
func NewRootSession(userID gid.GID, duration time.Duration) *Session {
return &Session{
ID: gid.New(gid.NilTenant, SessionEntityType),
UserID: userID,
ExpiredAt: time.Now().Add(duration),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
switch orderBy {
case SessionOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SessionOrderFieldExpiredAt:
return page.NewCursorKey(s.ID, s.ExpiredAt)
case SessionOrderFieldUpdatedAt:
return page.NewCursorKey(s.ID, s.UpdatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *Session) IsRootSession() bool {
return s.ParentSessionID == nil
}
func (s *Session) IsChildSession() bool {
return s.ParentSessionID != nil
}
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -82,7 +97,12 @@ func (s *Session) LoadByID(
SELECT
id,
user_id,
data,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
@@ -103,7 +123,7 @@ LIMIT 1;
session, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Session])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrSessionNotFound{Identifier: sessionID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect session: %w", err)
@@ -119,11 +139,16 @@ func (s *Session) Insert(
) error {
q := `
INSERT INTO
sessions (id, user_id, data, expired_at, created_at, updated_at)
sessions (id, user_id, tenant_id, data, parent_session_id, expire_reason, user_agent, ip_address, expired_at, created_at, updated_at)
VALUES (
@session_id,
@user_id,
@tenant_id,
@data,
@parent_session_id,
@expire_reason,
@user_agent,
@ip_address,
@expired_at,
@created_at,
@updated_at
@@ -131,12 +156,17 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"user_id": s.UserID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_id": s.UserID,
"tenant_id": s.TenantID,
"data": s.Data,
"parent_session_id": s.ParentSessionID,
"expire_reason": s.ExpireReason,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -152,36 +182,121 @@ UPDATE sessions
SET
expired_at = @expired_at,
updated_at = @updated_at,
user_agent = @user_agent,
ip_address = @ip_address,
expire_reason = @expire_reason,
data = @data
WHERE
id = @session_id
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expire_reason": s.ExpireReason,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func DeleteSession(
ctx context.Context,
conn pg.Conn,
sessionID gid.GID,
) error {
func (s *Sessions) LoadByUserID(ctx context.Context, conn pg.Conn, userID gid.GID, cursor *page.Cursor[SessionOrderField]) error {
q := `
DELETE FROM
SELECT
id,
user_id,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
FROM
sessions
WHERE
id = @session_id
user_id = @user_id
AND %s
`
args := pgx.StrictNamedArgs{"session_id": sessionID}
q = fmt.Sprintf(q, cursor.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
args := pgx.StrictNamedArgs{"user_id": userID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query sessions: %w", err)
}
sessions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Session])
if err != nil {
return fmt.Errorf("cannot collect sessions: %w", err)
}
*s = sessions
return nil
}
func (s *Sessions) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
sessions
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (s *Sessions) ExpireAllForUserExceptOneSession(ctx context.Context, conn pg.Conn, userID gid.GID, sessionID gid.GID) (int64, error) {
q := `
UPDATE sessions
SET
expired_at = NOW(),
updated_at = NOW(),
expire_reason = 'revoked'
WHERE
id != @session_id
AND user_id = @user_id
AND expire_reason IS NULL
`
args := pgx.StrictNamedArgs{
"session_id": sessionID,
"user_id": userID,
}
result, err := conn.Exec(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query sessions: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -20,9 +20,20 @@ type (
const (
SessionOrderFieldCreatedAt SessionOrderField = "CREATED_AT"
SessionOrderFieldExpiredAt SessionOrderField = "EXPIRED_AT"
SessionOrderFieldUpdatedAt SessionOrderField = "UPDATED_AT"
)
func (p SessionOrderField) Column() string {
switch p {
case SessionOrderFieldCreatedAt:
return "created_at"
case SessionOrderFieldExpiredAt:
return "expired_at"
case SessionOrderFieldUpdatedAt:
return "updated_at"
}
return string(p)
}

View File

@@ -41,24 +41,8 @@ type (
}
StatesOfApplicability []*StateOfApplicability
ErrStateOfApplicabilityNotFound struct {
Identifier string
}
ErrStateOfApplicabilityAlreadyExists struct {
message string
}
)
func (e ErrStateOfApplicabilityNotFound) Error() string {
return fmt.Sprintf("state of applicability not found: %s", e.Identifier)
}
func (e ErrStateOfApplicabilityAlreadyExists) Error() string {
return e.message
}
func (s StateOfApplicability) CursorKey(orderBy StateOfApplicabilityOrderField) page.CursorKey {
switch orderBy {
case StateOfApplicabilityOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
stateOfApplicability, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[StateOfApplicability])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrStateOfApplicabilityNotFound{Identifier: stateOfApplicabilityID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect state_of_applicability: %w", err)
@@ -246,9 +230,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert state_of_applicability: %w", err)
@@ -287,16 +269,14 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update state_of_applicability: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -325,7 +305,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -46,24 +46,8 @@ type (
}
Tasks []*Task
ErrTaskNotFound struct {
Identifier string
}
ErrTaskAlreadyExists struct {
message string
}
)
func (e ErrTaskNotFound) Error() string {
return fmt.Sprintf("task not found: %q", e.Identifier)
}
func (e ErrTaskAlreadyExists) Error() string {
return e.message
}
func (c Task) CursorKey(orderBy TaskOrderField) page.CursorKey {
switch orderBy {
case TaskOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTaskNotFound{Identifier: taskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect tasks: %w", err)
@@ -185,9 +169,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "tasks_reference_id_unique" {
return &ErrTaskAlreadyExists{
message: fmt.Sprintf("task with measure_id %s and reference_id %q already exists", c.MeasureID, c.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert task: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -41,24 +41,8 @@ type (
}
TrustCenters []*TrustCenter
ErrTrustCenterNotFound struct {
Identifier string
}
ErrTrustCenterAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterNotFound) Error() string {
return fmt.Sprintf("trust center not found: %q", e.Identifier)
}
func (e ErrTrustCenterAlreadyExists) Error() string {
return e.message
}
func (tc *TrustCenter) CursorKey(orderBy TrustCenterOrderField) page.CursorKey {
switch orderBy {
case TrustCenterOrderFieldCreatedAt:
@@ -239,9 +223,7 @@ INSERT INTO trust_centers (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_centers_slug_key" {
return &ErrTrustCenterAlreadyExists{
message: fmt.Sprintf("trust center with slug %q already exists", tc.Slug),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center: %w", err)

View File

@@ -48,24 +48,8 @@ type (
}
TrustCenterAccesses []*TrustCenterAccess
ErrTrustCenterAccessNotFound struct {
Identifier string
}
ErrTrustCenterAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterAccessNotFound) Error() string {
return fmt.Sprintf("trust center access not found: %s", e.Identifier)
}
func (e ErrTrustCenterAccessAlreadyExists) Error() string {
return e.message
}
func (tca *TrustCenterAccess) CursorKey(orderBy TrustCenterAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterAccessOrderFieldCreatedAt:
@@ -117,7 +101,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -175,7 +159,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: fmt.Sprintf("trust_center_id=%s, email=%s", trustCenterID, email)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -235,9 +219,7 @@ INSERT INTO trust_center_accesses (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_accesses_trust_center_id_email_key" {
return &ErrTrustCenterAccessAlreadyExists{
message: "trust center access already exists",
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center access: %w", err)

View File

@@ -42,24 +42,8 @@ type (
}
TrustCenterDocumentAccesses []*TrustCenterDocumentAccess
ErrTrustCenterDocumentAccessNotFound struct {
Identifier string
}
ErrTrustCenterDocumentAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterDocumentAccessNotFound) Error() string {
return fmt.Sprintf("trust center document access not found: %s", e.Identifier)
}
func (e ErrTrustCenterDocumentAccessAlreadyExists) Error() string {
return e.message
}
func (tcda *TrustCenterDocumentAccess) CursorKey(orderBy TrustCenterDocumentAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterDocumentAccessOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterDocumentAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterDocumentAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center document access: %w", err)
}
@@ -267,18 +251,10 @@ INSERT INTO trust_center_document_accesses (
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "trust_center_document_accesse_trust_center_access_id_docume_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and document_id %s already exists", tcda.TrustCenterAccessID, tcda.DocumentID),
}
case "trust_center_document_accesse_trust_center_access_id_report_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and report_id %s already exists", tcda.TrustCenterAccessID, tcda.ReportID),
}
case "trust_center_document_accesses_trust_center_file_id_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and trust_center_file_id %s already exists", tcda.TrustCenterAccessID, tcda.TrustCenterFileID),
}
case "trust_center_document_accesse_trust_center_access_id_docume_key",
"trust_center_document_accesse_trust_center_access_id_report_key",
"trust_center_document_accesses_trust_center_file_id_key":
return ErrResourceAlreadyExists
}
}
}

View File

@@ -43,24 +43,8 @@ type (
}
TrustCenterReferences []*TrustCenterReference
ErrTrustCenterReferenceNotFound struct {
Identifier string
}
ErrTrustCenterReferenceAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterReferenceNotFound) Error() string {
return fmt.Sprintf("trust center reference not found: %q", e.Identifier)
}
func (e ErrTrustCenterReferenceAlreadyExists) Error() string {
return e.message
}
func (t TrustCenterReference) CursorKey(orderBy TrustCenterReferenceOrderField) page.CursorKey {
switch orderBy {
case TrustCenterReferenceOrderFieldRank:
@@ -174,9 +158,7 @@ RETURNING rank;
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_references_trust_center_id_rank_key" {
return &ErrTrustCenterReferenceAlreadyExists{
message: fmt.Sprintf("trust center reference with trust_center_id %s and rank already exists", t.TrustCenterID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center reference: %w", err)
@@ -221,7 +203,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrTrustCenterReferenceNotFound{Identifier: t.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,24 +43,8 @@ type (
}
Users []*User
ErrUserNotFound struct {
Identifier string
}
ErrUserAlreadyExists struct {
message string
}
)
func (e ErrUserNotFound) Error() string {
return fmt.Sprintf("user not found: %q", e.Identifier)
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (u User) CursorKey(orderBy UserOrderField) page.CursorKey {
switch orderBy {
case UserOrderFieldCreatedAt:
@@ -180,7 +164,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: email.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -224,7 +208,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: userID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -238,7 +222,6 @@ LIMIT 1;
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
@@ -272,9 +255,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "email_address") {
return &ErrUserAlreadyExists{
message: fmt.Sprintf("user with email %s already exists", u.EmailAddress),
}
return ErrResourceAlreadyExists
}
}
@@ -284,71 +265,6 @@ VALUES (
return nil
}
func (u *User) UpdateEmailVerification(
ctx context.Context,
conn pg.Conn,
verified bool,
) error {
q := `
UPDATE
users
SET
email_address_verified = @email_address_verified,
updated_at = @updated_at
WHERE
id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address_verified": verified,
"updated_at": time.Now(),
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
u.EmailAddressVerified = verified
u.UpdatedAt = args["updated_at"].(time.Time)
return nil
}
func (u *User) UpdatePassword(
ctx context.Context,
conn pg.Conn,
hashedPassword []byte,
) error {
q := `
UPDATE
users
SET
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
`
now := time.Now()
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"hashed_password": hashedPassword,
"updated_at": now,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user password: %w", err)
}
u.HashedPassword = hashedPassword
u.UpdatedAt = now
return nil
}
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
q := `
UPDATE
@@ -357,6 +273,8 @@ SET
email_address = @email_address,
email_address_verified = @email_address_verified,
saml_subject = @saml_subject,
fullname = @fullname,
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
@@ -368,13 +286,19 @@ WHERE
"email_address_verified": u.EmailAddressVerified,
"saml_subject": u.SAMLSubject,
"updated_at": u.UpdatedAt,
"fullname": u.FullName,
"hashed_password": u.HashedPassword,
}
_, err := conn.Exec(ctx, q, args)
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
@@ -411,7 +335,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: samlSubject}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -422,10 +346,6 @@ LIMIT 1;
return nil
}
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
// All users are now global (no tenant_id distinction)
// Use LoadByEmail() for all email-based lookups
func (u *User) CountMemberships(
ctx context.Context,
conn pg.Conn,

View File

@@ -23,27 +23,30 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
UserAPIKey struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
ExpireReason *ExpireReason `db:"expire_reason"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
UserAPIKeys []*UserAPIKey
ErrUserAPIKeyNotFound struct {
Identifier string
}
)
func (e ErrUserAPIKeyNotFound) Error() string {
return fmt.Sprintf("user api key not found: %q", e.Identifier)
func (a *UserAPIKey) CursorKey(orderBy UserAPIKeyOrderField) page.CursorKey {
switch orderBy {
case UserAPIKeyOrderFieldCreatedAt:
return page.NewCursorKey(a.ID, a.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (a *UserAPIKey) LoadByID(
@@ -57,6 +60,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -76,7 +80,7 @@ LIMIT 1;
apiKey, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[UserAPIKey])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserAPIKeyNotFound{Identifier: apiKeyID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user api key: %w", err)
@@ -98,6 +102,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -124,30 +129,53 @@ ORDER BY created_at DESC;
return nil
}
func (a *UserAPIKeys) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_user_api_keys
WHERE
user_id = @user_id
ORDER BY created_at DESC;
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (a *UserAPIKey) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
auth_user_api_keys (id, user_id, name, expires_at, created_at, updated_at)
auth_user_api_keys (id, user_id, name, expires_at, expire_reason, created_at, updated_at)
VALUES (
@api_key_id,
@user_id,
@name,
@expires_at,
@expire_reason,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -168,16 +196,18 @@ UPDATE
SET
name = @name,
expires_at = @expires_at,
expire_reason = @expire_reason,
updated_at = @updated_at
WHERE
id = @api_key_id
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)

Some files were not shown because too many files have changed in this diff Show More