From 1b811011ebe9e5db8e1e299088006b9d9fc57cc0 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Wed, 1 Apr 2026 11:33:40 +0200 Subject: [PATCH] Split employee document policy from core document actions Introduce dedicated employee-scoped IAM actions and update all resolvers and frontend mutations accordingly. Signed-off-by: Sacha Al Himdani --- .../documents/approve/DocumentApprovePage.tsx | 27 +- .../EmployeeDocumentSignaturePage.tsx | 24 +- e2e/console/document_version_test.go | 39 +- e2e/console/employee_document_test.go | 1147 +++++++++++++++++ e2e/internal/testutil/client.go | 22 +- pkg/coredata/document_filter.go | 72 +- pkg/coredata/document_version_filter.go | 74 +- pkg/probo/actions.go | 6 +- pkg/probo/policies.go | 23 +- pkg/server/api/console/v1/schema.graphql | 10 +- .../api/console/v1/types/employee_document.go | 7 +- pkg/server/api/console/v1/v1_resolver.go | 57 +- 12 files changed, 1331 insertions(+), 177 deletions(-) create mode 100644 e2e/console/employee_document_test.go diff --git a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx index 496564e39..1e8fc9877 100644 --- a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx +++ b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx @@ -46,7 +46,7 @@ import type { DocumentApprovePage_approveMutation } from "#/__generated__/core/D import type { DocumentApprovePage_rejectMutation } from "#/__generated__/core/DocumentApprovePage_rejectMutation.graphql"; import type { DocumentApprovePageDecisionFragment$key } from "#/__generated__/core/DocumentApprovePageDecisionFragment.graphql"; import type { DocumentApprovePageDocumentFragment$key } from "#/__generated__/core/DocumentApprovePageDocumentFragment.graphql"; -import type { DocumentApprovePageExportPDFMutation } from "#/__generated__/core/DocumentApprovePageExportPDFMutation.graphql"; +import type { DocumentApprovePageExportEmployeePDFMutation } from "#/__generated__/core/DocumentApprovePageExportEmployeePDFMutation.graphql"; import type { DocumentApprovePageQuery } from "#/__generated__/core/DocumentApprovePageQuery.graphql"; import type { DocumentApprovePageVersionRowFragment$key } from "#/__generated__/core/DocumentApprovePageVersionRowFragment.graphql"; import { PDFPreview } from "#/components/documents/PDFPreview"; @@ -100,9 +100,6 @@ const decisionFragment = graphql` state canApprove: permission(action: "core:document-version:approve") canReject: permission(action: "core:document-version:reject") - documentVersion { - id - } } `; @@ -131,10 +128,10 @@ const rejectDocumentVersionMutation = graphql` `; const exportPDFMutation = graphql` - mutation DocumentApprovePageExportPDFMutation( - $input: ExportDocumentVersionPDFInput! + mutation DocumentApprovePageExportEmployeePDFMutation( + $input: ExportEmployeeDocumentVersionPDFInput! ) { - exportDocumentVersionPDF(input: $input) { + exportEmployeeDocumentVersionPDF(input: $input) { data } } @@ -227,9 +224,10 @@ function VersionRow({ function ViewerDecision(props: { fragmentRef: DocumentApprovePageDecisionFragment$key; + versionId: string; onBack: () => void; }) { - const { fragmentRef, onBack } = props; + const { fragmentRef, versionId, onBack } = props; const { __ } = useTranslate(); const decision = useFragment(decisionFragment, fragmentRef); const rejectDialogRef = useDialogRef(); @@ -311,7 +309,7 @@ function ViewerDecision(props: { approveVersion({ variables: { input: { - documentVersionId: decision.documentVersion.id, + documentVersionId: versionId, }, }, onCompleted(_, errors) { @@ -372,7 +370,7 @@ function ViewerDecision(props: { rejectVersion({ variables: { input: { - documentVersionId: decision.documentVersion.id, + documentVersionId: versionId, comment: rejectComment || undefined, }, }, @@ -434,7 +432,7 @@ function DocumentApproveContent({ usePageTitle(__("Review and Approve Document")); - const [exportPDF] = useMutation( + const [exportPDF] = useMutation( exportPDFMutation, ); @@ -448,8 +446,6 @@ function DocumentApproveContent({ variables: { input: { documentVersionId: selectedVersion.id, - withWatermark: true, - withSignatures: false, }, }, onCompleted: (data, errors): void => { @@ -464,8 +460,8 @@ function DocumentApproveContent({ }); return; } - if (data.exportDocumentVersionPDF?.data) { - const dataUrl = data.exportDocumentVersionPDF.data; + if (data.exportEmployeeDocumentVersionPDF?.data) { + const dataUrl = data.exportEmployeeDocumentVersionPDF.data; pdfUrlRef.current = dataUrl; setPdfUrl(dataUrl); } @@ -522,6 +518,7 @@ function DocumentApproveContent({ ? ( void navigate(`/organizations/${organizationId}/employee/approvals`)} /> diff --git a/apps/console/src/pages/organizations/employee/EmployeeDocumentSignaturePage.tsx b/apps/console/src/pages/organizations/employee/EmployeeDocumentSignaturePage.tsx index aac940100..7d13c29c0 100644 --- a/apps/console/src/pages/organizations/employee/EmployeeDocumentSignaturePage.tsx +++ b/apps/console/src/pages/organizations/employee/EmployeeDocumentSignaturePage.tsx @@ -28,7 +28,7 @@ import { useNavigate } from "react-router"; import { useWindowSize } from "usehooks-ts"; import type { EmployeeDocumentSignaturePageDocumentFragment$key } from "#/__generated__/core/EmployeeDocumentSignaturePageDocumentFragment.graphql"; -import type { EmployeeDocumentSignaturePageExportSignablePDFMutation } from "#/__generated__/core/EmployeeDocumentSignaturePageExportSignablePDFMutation.graphql"; +import type { EmployeeDocumentSignaturePageExportEmployeePDFMutation } from "#/__generated__/core/EmployeeDocumentSignaturePageExportEmployeePDFMutation.graphql"; import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql"; import type { EmployeeDocumentSignaturePageSignMutation } from "#/__generated__/core/EmployeeDocumentSignaturePageSignMutation.graphql"; import { PDFPreview } from "#/components/documents/PDFPreview"; @@ -80,11 +80,11 @@ const signDocumentMutation = graphql` } `; -const exportSignableVersionDocumentPDFMutation = graphql` - mutation EmployeeDocumentSignaturePageExportSignablePDFMutation( - $input: ExportSignableDocumentVersionPDFInput! +const exportEmployeeDocumentVersionPDFMutation = graphql` + mutation EmployeeDocumentSignaturePageExportEmployeePDFMutation( + $input: ExportEmployeeDocumentVersionPDFInput! ) { - exportSignableVersionDocumentPDF(input: $input) { + exportEmployeeDocumentVersionPDF(input: $input) { data } } @@ -145,9 +145,9 @@ function DocumentSignatureContent({ signDocumentMutation, ); - const [exportSignableVersionDocumentPDF] - = useMutation( - exportSignableVersionDocumentPDFMutation, + const [exportEmployeeDocumentVersionPDF] + = useMutation( + exportEmployeeDocumentVersionPDFMutation, ); const [pdfUrl, setPdfUrl] = useState(null); @@ -191,7 +191,7 @@ function DocumentSignatureContent({ useEffect(() => { if (!selectedVersion?.id) return; - exportSignableVersionDocumentPDF({ + exportEmployeeDocumentVersionPDF({ variables: { input: { documentVersionId: selectedVersion.id, @@ -209,8 +209,8 @@ function DocumentSignatureContent({ }); return; } - if (data.exportSignableVersionDocumentPDF?.data) { - const dataUrl = data.exportSignableVersionDocumentPDF.data; + if (data.exportEmployeeDocumentVersionPDF?.data) { + const dataUrl = data.exportEmployeeDocumentVersionPDF.data; pdfUrlRef.current = dataUrl; setPdfUrl(dataUrl); } @@ -230,7 +230,7 @@ function DocumentSignatureContent({ return () => { pdfUrlRef.current = null; }; - }, [selectedVersion?.id, exportSignableVersionDocumentPDF, toast, __]); + }, [selectedVersion?.id, exportEmployeeDocumentVersionPDF, toast, __]); return (
. +// +// 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 console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestEmployeeDocument_NodeAccess(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + employee := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + + docID, _ := createTestDocument(t, owner) + + t.Run("employee cannot list documents", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + query ListDocuments($orgId: ID!) { + node(id: $orgId) { + ... on Organization { + documents(first: 10) { + edges { node { id } } + } + } + } + } + `, map[string]any{"orgId": employee.GetOrganizationID().String()}) + testutil.RequireForbiddenError(t, err, "employee should not list documents") + }) + + t.Run("employee cannot access document via node query", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + query GetDocument($id: ID!) { + node(id: $id) { + ... on Document { + id + title + } + } + } + `, map[string]any{"id": docID}) + testutil.RequireForbiddenError(t, err, "employee should not access document via node") + }) + + t.Run("owner can access document via node query", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + query GetDocument($id: ID!) { + node(id: $id) { + ... on Document { + id + title + } + } + } + `, map[string]any{"id": docID}) + require.NoError(t, err, "owner should access document via node") + }) + + t.Run("employee cannot access signableDocument they are not signer of", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + query($docId: ID!) { + viewer { + signableDocument(id: $docId) { + id + } + } + } + `, map[string]any{"docId": docID}) + testutil.RequireErrorCode(t, err, "NOT_FOUND", "employee should not access signableDocument for doc they are not signer of") + }) + + t.Run("employee cannot access approvableDocument they are not approver of", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + query($docId: ID!) { + viewer { + approvableDocument(id: $docId) { + id + } + } + } + `, map[string]any{"docId": docID}) + testutil.RequireErrorCode(t, err, "NOT_FOUND", "employee should not access approvableDocument for doc they are not approver of") + }) +} + +func TestEmployeeDocument_ExportPDF(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + employee := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + + _, docVersionID := createTestDocument(t, owner) + + t.Run("employee cannot use exportDocumentVersionPDF", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + mutation ExportPDF($input: ExportDocumentVersionPDFInput!) { + exportDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": docVersionID, + "withWatermark": false, + "withSignatures": false, + }, + }) + testutil.RequireForbiddenError(t, err, "employee should not use exportDocumentVersionPDF") + }) + + t.Run("owner can use exportDocumentVersionPDF", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + mutation ExportPDF($input: ExportDocumentVersionPDFInput!) { + exportDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": docVersionID, + "withWatermark": false, + "withSignatures": false, + }, + }) + require.NoError(t, err, "owner should use exportDocumentVersionPDF") + }) + + t.Run("employee cannot use exportEmployeeDocumentVersionPDF without being signer", func(t *testing.T) { + t.Parallel() + + _, err := employee.Do(` + mutation ExportEmployeePDF($input: ExportEmployeeDocumentVersionPDFInput!) { + exportEmployeeDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": docVersionID, + }, + }) + testutil.RequireErrorCode(t, err, "NOT_FOUND", "employee should not export PDF for document they are not signer of") + }) +} + +func TestEmployeeDocument_SignableDocuments(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + employee := testutil.NewClientInOrg(t, testutil.RoleEmployee, owner) + + t.Run("employee can list signable documents", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + SignableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"signableDocuments"` + } `json:"viewer"` + } + + err := employee.Execute(` + query($orgId: ID!) { + viewer { + signableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": employee.GetOrganizationID().String()}, &result) + require.NoError(t, err, "employee should list signable documents") + }) + + t.Run("employee can list approvable documents", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + ApprovableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"approvableDocuments"` + } `json:"viewer"` + } + + err := employee.Execute(` + query($orgId: ID!) { + viewer { + approvableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": employee.GetOrganizationID().String()}, &result) + require.NoError(t, err, "employee should list approvable documents") + }) + + t.Run("employee does not see document they are not signer of in signableDocuments", func(t *testing.T) { + t.Parallel() + + // Create a document with signature requested from owner only + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + ownerProfileID := owner.GetProfileID().String() + _, err = owner.Do(` + mutation($input: RequestSignatureInput!) { + requestSignature(input: $input) { + documentVersionSignatureEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionResult.Node.Versions.Edges[0].Node.ID, + "signatoryId": ownerProfileID, + }, + }) + require.NoError(t, err) + + // Employee should not see this document + var listResult struct { + Viewer struct { + SignableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"signableDocuments"` + } `json:"viewer"` + } + + err = employee.Execute(` + query($orgId: ID!) { + viewer { + signableDocuments(organizationId: $orgId, first: 100) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": employee.GetOrganizationID().String()}, &listResult) + require.NoError(t, err) + + for _, edge := range listResult.Viewer.SignableDocuments.Edges { + assert.NotEqual(t, docID, edge.Node.ID, "employee should not see document they are not signer of") + } + }) + + t.Run("employee does not see document they are not approver of in approvableDocuments", func(t *testing.T) { + t.Parallel() + + // Create a document with approval requested from owner only + docID, _ := createTestDocument(t, owner) + ownerProfileID := owner.GetProfileID().String() + + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{ownerProfileID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + // Employee should not see this document + var listResult struct { + Viewer struct { + ApprovableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"approvableDocuments"` + } `json:"viewer"` + } + + err = employee.Execute(` + query($orgId: ID!) { + viewer { + approvableDocuments(organizationId: $orgId, first: 100) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": employee.GetOrganizationID().String()}, &listResult) + require.NoError(t, err) + + for _, edge := range listResult.Viewer.ApprovableDocuments.Edges { + assert.NotEqual(t, docID, edge.Node.ID, "employee should not see document they are not approver of") + } + }) +} + +func TestEmployeeDocument_FilterModeIsolation(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) + + // Create a document and have the admin approve it (so owner is NOT an approver) + docID, _ := createTestDocument(t, owner) + + adminProfileID := admin.GetProfileID().String() + + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{adminProfileID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + // Get the version ID created by the approval request + var approveVersionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &approveVersionResult) + require.NoError(t, err) + require.NotEmpty(t, approveVersionResult.Node.Versions.Edges) + + pendingVersionID := approveVersionResult.Node.Versions.Edges[0].Node.ID + + _, err = admin.Do(` + mutation($input: ApproveDocumentVersionInput!) { + approveDocumentVersion(input: $input) { + approvalDecision { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": pendingVersionID, + }, + }) + require.NoError(t, err) + + // Get the published version ID + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID + ownerProfileID := owner.GetProfileID().String() + + // Request signature from the owner only (owner is a signer but NOT an approver) + _, err = owner.Do(` + mutation($input: RequestSignatureInput!) { + requestSignature(input: $input) { + documentVersionSignatureEdge { + node { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + "signatoryId": ownerProfileID, + }, + }) + require.NoError(t, err) + + t.Run("signer sees document in signableDocuments list", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + SignableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"signableDocuments"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($orgId: ID!) { + viewer { + signableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": owner.GetOrganizationID().String()}, &result) + require.NoError(t, err) + + var found bool + for _, edge := range result.Viewer.SignableDocuments.Edges { + if edge.Node.ID == docID { + found = true + break + } + } + assert.True(t, found, "signer should see document in signableDocuments list") + }) + + t.Run("signer does not see document in approvableDocuments list", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + ApprovableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"approvableDocuments"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($orgId: ID!) { + viewer { + approvableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": owner.GetOrganizationID().String()}, &result) + require.NoError(t, err) + + for _, edge := range result.Viewer.ApprovableDocuments.Edges { + assert.NotEqual(t, docID, edge.Node.ID, "signer-only document should not appear in approvableDocuments list") + } + }) + + t.Run("signer cannot access document via approvableDocument", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + query($docId: ID!) { + viewer { + approvableDocument(id: $docId) { + id + } + } + } + `, map[string]any{"docId": docID}) + testutil.RequireErrorCode(t, err, "NOT_FOUND", "signer-only should not access approvableDocument") + }) +} + +func TestEmployeeDocument_ApproverFilterModeIsolation(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create a document and request approval (owner is approver but NOT a signer) + docID, _ := createTestDocument(t, owner) + ownerProfileID := owner.GetProfileID().String() + + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{ownerProfileID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + t.Run("approver sees document in approvableDocuments list", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + ApprovableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"approvableDocuments"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($orgId: ID!) { + viewer { + approvableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": owner.GetOrganizationID().String()}, &result) + require.NoError(t, err) + + var found bool + for _, edge := range result.Viewer.ApprovableDocuments.Edges { + if edge.Node.ID == docID { + found = true + break + } + } + assert.True(t, found, "approver should see document in approvableDocuments list") + }) + + t.Run("approver does not see document in signableDocuments list", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + SignableDocuments struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"signableDocuments"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($orgId: ID!) { + viewer { + signableDocuments(organizationId: $orgId, first: 10) { + edges { node { id } } + } + } + } + `, map[string]any{"orgId": owner.GetOrganizationID().String()}, &result) + require.NoError(t, err) + + for _, edge := range result.Viewer.SignableDocuments.Edges { + assert.NotEqual(t, docID, edge.Node.ID, "approver-only document should not appear in signableDocuments list") + } + }) + + t.Run("approver cannot access document via signableDocument", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + query($docId: ID!) { + viewer { + signableDocument(id: $docId) { + id + } + } + } + `, map[string]any{"docId": docID}) + testutil.RequireErrorCode(t, err, "NOT_FOUND", "approver-only should not access signableDocument") + }) +} + +func TestEmployeeDocument_UnsignedDocument(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create a document, approve and publish it, then request signature from owner + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID + ownerProfileID := owner.GetProfileID().String() + + _, err = owner.Do(` + mutation($input: RequestSignatureInput!) { + requestSignature(input: $input) { + documentVersionSignatureEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + "signatoryId": ownerProfileID, + }, + }) + require.NoError(t, err) + + t.Run("unsigned document shows signed=false on version", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + SignableDocument struct { + ID string `json:"id"` + Signed *bool `json:"signed"` + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Signed bool `json:"signed"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"signableDocument"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($docId: ID!) { + viewer { + signableDocument(id: $docId) { + id + signed + versions(first: 10) { + edges { + node { + id + signed + } + } + } + } + } + } + `, map[string]any{"docId": docID}, &result) + require.NoError(t, err) + require.NotNil(t, result.Viewer.SignableDocument.Signed) + assert.False(t, *result.Viewer.SignableDocument.Signed, "document should not be signed yet") + require.NotEmpty(t, result.Viewer.SignableDocument.Versions.Edges) + assert.False(t, result.Viewer.SignableDocument.Versions.Edges[0].Node.Signed, "version should not be signed yet") + }) + + t.Run("unsigned document still allows exportEmployeeDocumentVersionPDF", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + mutation($input: ExportEmployeeDocumentVersionPDFInput!) { + exportEmployeeDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) + require.NoError(t, err, "signer should export PDF even before signing") + }) +} + +func TestEmployeeDocument_UnapprovedDocument(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create a document and request approval from owner (don't approve yet) + docID, _ := createTestDocument(t, owner) + ownerProfileID := owner.GetProfileID().String() + + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{ownerProfileID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + t.Run("unapproved document shows PENDING state", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + ApprovableDocument struct { + ID string `json:"id"` + ApprovalState *string `json:"approvalState"` + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + ApprovalDecision *struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"approvalDecision"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"approvableDocument"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($docId: ID!) { + viewer { + approvableDocument(id: $docId) { + id + approvalState + versions(first: 10) { + edges { + node { + id + approvalDecision { + id + state + } + } + } + } + } + } + } + `, map[string]any{"docId": docID}, &result) + require.NoError(t, err) + require.NotNil(t, result.Viewer.ApprovableDocument.ApprovalState) + assert.Equal(t, "PENDING", *result.Viewer.ApprovableDocument.ApprovalState, "approval state should be PENDING") + require.NotEmpty(t, result.Viewer.ApprovableDocument.Versions.Edges) + require.NotNil(t, result.Viewer.ApprovableDocument.Versions.Edges[0].Node.ApprovalDecision) + assert.Equal(t, "PENDING", result.Viewer.ApprovableDocument.Versions.Edges[0].Node.ApprovalDecision.State, "decision should be PENDING") + }) + + t.Run("unapproved document still allows exportEmployeeDocumentVersionPDF", func(t *testing.T) { + t.Parallel() + + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + _, err = owner.Do(` + mutation($input: ExportEmployeeDocumentVersionPDFInput!) { + exportEmployeeDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionResult.Node.Versions.Edges[0].Node.ID, + }, + }) + require.NoError(t, err, "approver should export PDF even before approving") + }) +} + +func TestEmployeeDocument_SignableDocumentNestedFields(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create a document, approve and publish it + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + // Get the published version ID + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID + + // Request signature from the owner + ownerProfileID := owner.GetProfileID().String() + + _, err = owner.Do(` + mutation($input: RequestSignatureInput!) { + requestSignature(input: $input) { + documentVersionSignatureEdge { + node { id state } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + "signatoryId": ownerProfileID, + }, + }) + require.NoError(t, err) + + t.Run("owner can access signableDocument with nested fields", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + SignableDocument struct { + ID string `json:"id"` + Title string `json:"title"` + Signed *bool `json:"signed"` + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Signed bool `json:"signed"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"signableDocument"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($docId: ID!) { + viewer { + signableDocument(id: $docId) { + id + title + signed + versions(first: 10) { + edges { + node { + id + signed + } + } + } + } + } + } + `, map[string]any{"docId": docID}, &result) + require.NoError(t, err, "owner should access signableDocument with nested fields") + assert.NotEmpty(t, result.Viewer.SignableDocument.ID) + assert.NotNil(t, result.Viewer.SignableDocument.Signed) + assert.NotEmpty(t, result.Viewer.SignableDocument.Versions.Edges) + }) + + t.Run("owner can use exportEmployeeDocumentVersionPDF as signer", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + mutation($input: ExportEmployeeDocumentVersionPDFInput!) { + exportEmployeeDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) + require.NoError(t, err, "owner signer should use exportEmployeeDocumentVersionPDF") + }) +} + +func TestEmployeeDocument_ApprovableDocumentNestedFields(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create a document and request approval with the owner as approver + docID, _ := createTestDocument(t, owner) + ownerProfileID := owner.GetProfileID().String() + + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{ownerProfileID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + t.Run("owner can access approvableDocument with nested fields", func(t *testing.T) { + t.Parallel() + + var result struct { + Viewer struct { + ApprovableDocument struct { + ID string `json:"id"` + Title string `json:"title"` + ApprovalState *string `json:"approvalState"` + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + ApprovalDecision *struct { + ID string `json:"id"` + State string `json:"state"` + } `json:"approvalDecision"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"approvableDocument"` + } `json:"viewer"` + } + + err := owner.Execute(` + query($docId: ID!) { + viewer { + approvableDocument(id: $docId) { + id + title + approvalState + versions(first: 10) { + edges { + node { + id + approvalDecision { + id + state + } + } + } + } + } + } + } + `, map[string]any{"docId": docID}, &result) + require.NoError(t, err, "owner should access approvableDocument with nested fields") + assert.NotEmpty(t, result.Viewer.ApprovableDocument.ID) + assert.NotEmpty(t, result.Viewer.ApprovableDocument.Versions.Edges) + }) + + t.Run("owner can use exportEmployeeDocumentVersionPDF as approver", func(t *testing.T) { + t.Parallel() + + // Get the version ID + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + versionID := versionResult.Node.Versions.Edges[0].Node.ID + + _, err = owner.Do(` + mutation($input: ExportEmployeeDocumentVersionPDFInput!) { + exportEmployeeDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + }, + }) + require.NoError(t, err, "owner approver should use exportEmployeeDocumentVersionPDF") + }) +} diff --git a/e2e/internal/testutil/client.go b/e2e/internal/testutil/client.go index 7449f6519..06dd89867 100644 --- a/e2e/internal/testutil/client.go +++ b/e2e/internal/testutil/client.go @@ -38,9 +38,11 @@ func generateUniqueID() string { type TestRole string const ( - RoleOwner TestRole = "OWNER" - RoleAdmin TestRole = "ADMIN" - RoleViewer TestRole = "VIEWER" + RoleOwner TestRole = "OWNER" + RoleAdmin TestRole = "ADMIN" + RoleViewer TestRole = "VIEWER" + RoleEmployee TestRole = "EMPLOYEE" + RoleAuditor TestRole = "AUDITOR" ) type Client struct { @@ -50,6 +52,7 @@ type Client struct { mailpitBaseURL string role TestRole userID gid.GID + profileID gid.GID organizationID gid.GID } @@ -129,6 +132,7 @@ func (c *Client) SetupTestUserInOrg(ownerClient *Client) { // Owner invites user to organization profileID, identityID := ownerClient.createUser(email, fullName, coredata.MembershipRole(c.role)) c.userID = identityID + c.profileID = profileID ownerClient.inviteUser(profileID) token := c.getActivationToken(email) passwordToken := c.activateUser(token) @@ -212,6 +216,9 @@ func (c *Client) createOrganization(name string) gid.GID { Organization struct { ID string `json:"id"` } `json:"organization"` + Profile struct { + ID string `json:"id"` + } `json:"profile"` } `json:"createOrganization"` } @@ -223,6 +230,11 @@ func (c *Client) createOrganization(name string) gid.GID { orgID, err := gid.ParseGID(result.CreateOrganization.Organization.ID) require.NoError(c.T, err, "cannot parse organization ID") + profileID, err := gid.ParseGID(result.CreateOrganization.Profile.ID) + require.NoError(c.T, err, "cannot parse profile ID") + + c.profileID = profileID + return orgID } @@ -483,6 +495,10 @@ func (c *Client) GetUserID() gid.GID { return c.userID } +func (c *Client) GetProfileID() gid.GID { + return c.profileID +} + func (c *Client) GetOrganizationID() gid.GID { return c.organizationID } diff --git a/pkg/coredata/document_filter.go b/pkg/coredata/document_filter.go index 9690d6fb3..a6a4b39c9 100644 --- a/pkg/coredata/document_filter.go +++ b/pkg/coredata/document_filter.go @@ -17,7 +17,6 @@ package coredata import ( "github.com/jackc/pgx/v5" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/mail" ) type ( @@ -25,8 +24,8 @@ type ( query *string trustCenterVisibilities []TrustCenterVisibility published *bool - userEmail *mail.Addr - approverIdentityID *gid.GID + employeeIdentityID *gid.GID + employeeFilterModes []EmployeeFilterMode documentTypes []DocumentType classifications []DocumentClassification status []DocumentStatus @@ -56,13 +55,9 @@ func (f *DocumentFilter) WithPublished(published *bool) *DocumentFilter { return f } -func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter { - f.userEmail = userEmail - return f -} - -func (f *DocumentFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentFilter { - f.approverIdentityID = identityID +func (f *DocumentFilter) WithEmployeeIdentityID(identityID *gid.GID, modes ...EmployeeFilterMode) *DocumentFilter { + f.employeeIdentityID = identityID + f.employeeFilterModes = modes return f } @@ -114,12 +109,17 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs { } } + var employeeFilterModes []string + for _, m := range f.employeeFilterModes { + employeeFilterModes = append(employeeFilterModes, string(m)) + } + return pgx.NamedArgs{ "query": f.query, "trust_center_visibilities": visibilities, "published": f.published, - "user_email": f.userEmail, - "approver_identity_id": f.approverIdentityID, + "employee_identity_id": f.employeeIdentityID, + "employee_filter_modes": employeeFilterModes, "document_types": documentTypes, "classifications": classifications, "document_status": status, @@ -151,30 +151,30 @@ func (f *DocumentFilter) SQLFragment() string { END AND CASE - WHEN @user_email::text IS NULL THEN TRUE - ELSE EXISTS ( - SELECT 1 - FROM document_versions dv - INNER JOIN document_version_signatures dvs ON dv.id = dvs.document_version_id - INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id - INNER JOIN identities i ON p.identity_id = i.id - WHERE dv.document_id = documents.id - AND dv.status = 'PUBLISHED' - AND i.email_address = @user_email::CITEXT - AND dvs.state IN ('REQUESTED', 'SIGNED') - ) - END - AND - CASE - WHEN @approver_identity_id::text IS NULL THEN TRUE - ELSE EXISTS ( - SELECT 1 - FROM document_versions dv - INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id - INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id - INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id - WHERE dv.document_id = documents.id - AND p.identity_id = @approver_identity_id::text + WHEN @employee_identity_id::text IS NULL THEN TRUE + ELSE ( + ( + 'signature' = ANY(@employee_filter_modes::text[]) AND EXISTS ( + SELECT 1 + FROM document_versions dv + INNER JOIN document_version_signatures dvs ON dv.id = dvs.document_version_id + INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id + WHERE dv.document_id = documents.id + AND p.identity_id = @employee_identity_id::text + AND dvs.state IN ('REQUESTED', 'SIGNED') + ) + ) + OR ( + 'approval' = ANY(@employee_filter_modes::text[]) AND EXISTS ( + SELECT 1 + FROM document_versions dv + INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id + INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id + INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id + WHERE dv.document_id = documents.id + AND p.identity_id = @employee_identity_id::text + ) + ) ) END AND diff --git a/pkg/coredata/document_version_filter.go b/pkg/coredata/document_version_filter.go index 497cdc00c..44163eb2f 100644 --- a/pkg/coredata/document_version_filter.go +++ b/pkg/coredata/document_version_filter.go @@ -17,14 +17,20 @@ package coredata import ( "github.com/jackc/pgx/v5" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/mail" +) + +type EmployeeFilterMode string + +const ( + EmployeeFilterModeSignature EmployeeFilterMode = "signature" + EmployeeFilterModeApproval EmployeeFilterMode = "approval" ) type ( DocumentVersionFilter struct { - statuses []DocumentVersionStatus - userEmail *mail.Addr - approverIdentityID *gid.GID + statuses []DocumentVersionStatus + employeeIdentityID *gid.GID + employeeFilterModes []EmployeeFilterMode } ) @@ -37,13 +43,9 @@ func (f *DocumentVersionFilter) WithStatuses(statuses ...DocumentVersionStatus) return f } -func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter { - f.userEmail = userEmail - return f -} - -func (f *DocumentVersionFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentVersionFilter { - f.approverIdentityID = identityID +func (f *DocumentVersionFilter) WithEmployeeIdentityID(identityID *gid.GID, modes ...EmployeeFilterMode) *DocumentVersionFilter { + f.employeeIdentityID = identityID + f.employeeFilterModes = modes return f } @@ -53,10 +55,15 @@ func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs { filterStatuses = append(filterStatuses, s.String()) } + var employeeFilterModes []string + for _, m := range f.employeeFilterModes { + employeeFilterModes = append(employeeFilterModes, string(m)) + } + return pgx.StrictNamedArgs{ - "filter_statuses": filterStatuses, - "user_email": f.userEmail, - "approver_identity_id": f.approverIdentityID, + "filter_statuses": filterStatuses, + "employee_identity_id": f.employeeIdentityID, + "employee_filter_modes": employeeFilterModes, } } @@ -69,27 +76,26 @@ func (f *DocumentVersionFilter) SQLFragment() string { ) AND ( - @user_email::text IS NULL - OR EXISTS ( - SELECT 1 - FROM document_version_signatures dvs - INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id - INNER JOIN identities i ON p.identity_id = i.id - WHERE dvs.document_version_id = document_versions.id - AND i.email_address = @user_email::CITEXT - AND dvs.state IN ('REQUESTED', 'SIGNED') + @employee_identity_id::text IS NULL + OR ( + 'signature' = ANY(@employee_filter_modes::text[]) AND EXISTS ( + SELECT 1 + FROM document_version_signatures dvs + INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id + WHERE dvs.document_version_id = document_versions.id + AND p.identity_id = @employee_identity_id::text + AND dvs.state IN ('REQUESTED', 'SIGNED') + ) ) - ) - AND - ( - @approver_identity_id::text IS NULL - OR EXISTS ( - SELECT 1 - FROM document_version_approval_quorums dvaq - INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id - INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id - WHERE dvaq.version_id = document_versions.id - AND p.identity_id = @approver_identity_id::text + OR ( + 'approval' = ANY(@employee_filter_modes::text[]) AND EXISTS ( + SELECT 1 + FROM document_version_approval_quorums dvaq + INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id + INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id + WHERE dvaq.version_id = document_versions.id + AND p.identity_id = @employee_identity_id::text + ) ) ) )` diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index eb9fd28fa..b06cedaf6 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -190,7 +190,6 @@ const ( ActionDocumentVersionGet = "core:document-version:get" ActionDocumentVersionList = "core:document-version:list" ActionDocumentVersionExportPDF = "core:document-version:export-pdf" - ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf" ActionDocumentVersionSign = "core:document-version:sign" ActionDocumentVersionUpdate = "core:document-version:update" ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft" @@ -203,6 +202,11 @@ const ( ActionDocumentVersionPublish = "core:document-version:publish" ActionDocumentVersionExport = "core:document-version:export" + // EmployeeDocument actions + ActionEmployeeDocumentGet = "core:employee-document:get" + ActionEmployeeDocumentList = "core:employee-document:list" + ActionEmployeeDocumentVersionExportPDF = "core:employee-document-version:export-pdf" + // DocumentVersionSignature actions ActionDocumentVersionSignatureRequest = "core:document-version-signature:request" ActionDocumentVersionCancelSignature = "core:document-version-signature:cancel" diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index b3a2c37e9..a978a0c38 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -162,13 +162,18 @@ var ViewerPolicy = policy.NewPolicy( policy.Allow(ActionCustomDomainGet).WithSID("custom-domain-read").When(organizationCondition), policy.Allow(ActionOrganizationContextGet).WithSID("organization-context-read").When(organizationCondition), policy.Allow( - ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign, + ActionDocumentVersionExportPDF, ActionDocumentVersionSign, ).WithSID("document-signing").When(organizationCondition), policy.Allow( ActionDocumentVersionApprove, ActionDocumentVersionReject, ).WithSID("document-approval").When(organizationCondition), + policy.Allow( + ActionEmployeeDocumentGet, ActionEmployeeDocumentList, + ActionEmployeeDocumentVersionExportPDF, + ).WithSID("employee-document-access").When(organizationCondition), + policy.Allow( ActionProcessingActivityExport, ActionDataProtectionImpactAssessmentExport, @@ -219,9 +224,14 @@ var AuditorPolicy = policy.NewPolicy( ).WithSID("entity-read-access").When(organizationCondition), policy.Allow( - ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign, + ActionDocumentVersionExportPDF, ActionDocumentVersionSign, ).WithSID("document-signing").When(organizationCondition), + policy.Allow( + ActionEmployeeDocumentGet, ActionEmployeeDocumentList, + ActionEmployeeDocumentVersionExportPDF, + ).WithSID("employee-document-access").When(organizationCondition), + policy.Allow( ActionStateOfApplicabilityExport, ).WithSID("soa-export").When(organizationCondition), @@ -238,20 +248,19 @@ var EmployeePolicy = policy.NewPolicy( ).WithSID("org-basic-access").When(organizationCondition), policy.Allow( - ActionDocumentGet, ActionDocumentList, - ).WithSID("document-signing-access").When(organizationCondition), + ActionEmployeeDocumentGet, ActionEmployeeDocumentList, + ).WithSID("employee-document-access").When(organizationCondition), policy.Allow( - ActionDocumentVersionGet, ActionDocumentVersionList, ActionDocumentVersionSign, - ActionDocumentVersionExportSignable, + ActionEmployeeDocumentVersionExportPDF, ).WithSID("document-version-signing").When(organizationCondition), policy.Allow( ActionDocumentVersionApprovalList, ActionDocumentVersionApprove, ActionDocumentVersionReject, - ActionDocumentVersionExportPDF, + ActionEmployeeDocumentVersionExportPDF, ).WithSID("document-version-approval").When(organizationCondition), ).WithDescription("Employee access - can sign documents, approve documents, and view internal content") diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 5741e9c22..42464ac5a 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -3860,9 +3860,9 @@ type Mutation { exportDocumentVersionPDF( input: ExportDocumentVersionPDFInput! ): ExportDocumentVersionPDFPayload! - exportSignableVersionDocumentPDF( - input: ExportSignableDocumentVersionPDFInput! - ): ExportSignableDocumentVersionPDFPayload! + exportEmployeeDocumentVersionPDF( + input: ExportEmployeeDocumentVersionPDFInput! + ): ExportEmployeeDocumentVersionPDFPayload! exportProcessingActivitiesPDF( input: ExportProcessingActivitiesPDFInput! ): ExportProcessingActivitiesPDFPayload! @@ -4492,7 +4492,7 @@ input ExportDocumentVersionPDFInput { withSignatures: Boolean! } -input ExportSignableDocumentVersionPDFInput { +input ExportEmployeeDocumentVersionPDFInput { documentVersionId: ID! } @@ -5222,7 +5222,7 @@ type ExportDocumentVersionPDFPayload { data: String! } -type ExportSignableDocumentVersionPDFPayload { +type ExportEmployeeDocumentVersionPDFPayload { data: String! } diff --git a/pkg/server/api/console/v1/types/employee_document.go b/pkg/server/api/console/v1/types/employee_document.go index 02be4011b..f07f8afa7 100644 --- a/pkg/server/api/console/v1/types/employee_document.go +++ b/pkg/server/api/console/v1/types/employee_document.go @@ -23,11 +23,11 @@ import ( "go.probo.inc/probo/pkg/page" ) -type EmployeeDocumentFilterMode int +type EmployeeDocumentFilterMode string const ( - EmployeeDocumentFilterModeSignature EmployeeDocumentFilterMode = iota - EmployeeDocumentFilterModeApproval + EmployeeDocumentFilterModeSignature EmployeeDocumentFilterMode = "SIGNATURE" + EmployeeDocumentFilterModeApproval EmployeeDocumentFilterMode = "APPROVAL" ) type ( @@ -64,6 +64,7 @@ type ( EmployeeDocumentVersion struct { ID gid.GID + DocumentID gid.GID OrganizationID gid.GID Major int Minor int diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 28a5b0b22..23b9b50ee 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1564,7 +1564,7 @@ func (r *electronicSignatureResolver) Events(ctx context.Context, obj *types.Ele // Signed is the resolver for the signed field. func (r *employeeDocumentResolver) Signed(ctx context.Context, obj *types.EmployeeDocument) (*bool, error) { - if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + if err := r.authorize(ctx, obj.ID, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -1586,7 +1586,7 @@ func (r *employeeDocumentResolver) Signed(ctx context.Context, obj *types.Employ // ApprovalState is the resolver for the approvalState field. func (r *employeeDocumentResolver) ApprovalState(ctx context.Context, obj *types.EmployeeDocument) (*coredata.DocumentVersionApprovalDecisionState, error) { - if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + if err := r.authorize(ctx, obj.ID, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -1608,7 +1608,7 @@ func (r *employeeDocumentResolver) ApprovalState(ctx context.Context, obj *types // Versions is the resolver for the versions field. func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.EmployeeDocument, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DocumentVersionOrderBy) (*types.EmployeeDocumentVersionConnection, error) { - if err := r.authorize(ctx, obj.ID, probo.ActionDocumentVersionList); err != nil { + if err := r.authorize(ctx, obj.ID, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -1629,14 +1629,20 @@ func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.Empl identity := authn.IdentityFromContext(ctx) - versionFilter := coredata.NewDocumentVersionFilter() + var filterMode coredata.EmployeeFilterMode switch obj.FilterMode { case types.EmployeeDocumentFilterModeSignature: - versionFilter = versionFilter.WithUserEmail(&identity.EmailAddress) + filterMode = coredata.EmployeeFilterModeSignature case types.EmployeeDocumentFilterModeApproval: - versionFilter = versionFilter.WithApproverIdentityID(&identity.ID) + filterMode = coredata.EmployeeFilterModeApproval + default: + r.logger.ErrorCtx(ctx, "unsupported employee document filter mode", log.String("filter_mode", string(obj.FilterMode))) + return nil, gqlutils.Internal(ctx) } + versionFilter := coredata.NewDocumentVersionFilter(). + WithEmployeeIdentityID(&identity.ID, filterMode) + versionsPage, err := prb.Documents.ListVersions(ctx, obj.ID, cursor, versionFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list employee document versions", log.Error(err)) @@ -1647,6 +1653,7 @@ func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.Empl for i, v := range versionsPage.Data { employeeVersions[i] = &types.EmployeeDocumentVersion{ ID: v.ID, + DocumentID: obj.ID, OrganizationID: v.OrganizationID, Major: v.Major, Minor: v.Minor, @@ -1665,7 +1672,7 @@ func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.Empl // Signed is the resolver for the signed field. func (r *employeeDocumentVersionResolver) Signed(ctx context.Context, obj *types.EmployeeDocumentVersion) (bool, error) { - if err := r.authorize(ctx, obj.ID, probo.ActionDocumentVersionGet); err != nil { + if err := r.authorize(ctx, obj.DocumentID, probo.ActionEmployeeDocumentGet); err != nil { return false, err } @@ -1684,7 +1691,7 @@ func (r *employeeDocumentVersionResolver) Signed(ctx context.Context, obj *types // ApprovalDecision is the resolver for the approvalDecision field. func (r *employeeDocumentVersionResolver) ApprovalDecision(ctx context.Context, obj *types.EmployeeDocumentVersion) (*types.DocumentVersionApprovalDecision, error) { - if err := r.authorize(ctx, obj.ID, probo.ActionDocumentVersionApprovalList); err != nil { + if err := r.authorize(ctx, obj.DocumentID, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -5708,9 +5715,9 @@ func (r *mutationResolver) ExportDocumentVersionPDF(ctx context.Context, input t }, nil } -// ExportSignableVersionDocumentPDF is the resolver for the exportSignableVersionDocumentPDF field. -func (r *mutationResolver) ExportSignableVersionDocumentPDF(ctx context.Context, input types.ExportSignableDocumentVersionPDFInput) (*types.ExportSignableDocumentVersionPDFPayload, error) { - if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionExportSignable); err != nil { +// ExportEmployeeDocumentVersionPDF is the resolver for the exportEmployeeDocumentVersionPDF field. +func (r *mutationResolver) ExportEmployeeDocumentVersionPDF(ctx context.Context, input types.ExportEmployeeDocumentVersionPDFInput) (*types.ExportEmployeeDocumentVersionPDFPayload, error) { + if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionEmployeeDocumentVersionExportPDF); err != nil { return nil, err } @@ -5723,7 +5730,11 @@ func (r *mutationResolver) ExportSignableVersionDocumentPDF(ctx context.Context, } identity := authn.IdentityFromContext(ctx) - documentFilter := coredata.NewDocumentFilter(nil).WithUserEmail(&identity.EmailAddress) + documentFilter := coredata.NewDocumentFilter(nil).WithEmployeeIdentityID( + &identity.ID, + coredata.EmployeeFilterModeSignature, + coredata.EmployeeFilterModeApproval, + ) _, err = prb.Documents.GetWithFilter(ctx, documentVersion.DocumentID, documentFilter) if err != nil { @@ -5731,7 +5742,7 @@ func (r *mutationResolver) ExportSignableVersionDocumentPDF(ctx context.Context, return nil, gqlutils.NotFound(ctx, err) } - r.logger.ErrorCtx(ctx, "cannot get signable document", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot get employee document", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -5743,11 +5754,11 @@ func (r *mutationResolver) ExportSignableVersionDocumentPDF(ctx context.Context, pdf, err := prb.Documents.ExportPDF(ctx, input.DocumentVersionID, options) if err != nil { - r.logger.ErrorCtx(ctx, "cannot export signable document PDF", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot export employee document PDF", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return &types.ExportSignableDocumentVersionPDFPayload{ + return &types.ExportEmployeeDocumentVersionPDFPayload{ Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)), }, nil } @@ -10201,7 +10212,7 @@ func (r *vendorServiceResolver) Permission(ctx context.Context, obj *types.Vendo // SignableDocuments is the resolver for the signableDocuments field. func (r *viewerResolver) SignableDocuments(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DocumentOrderBy) (*types.EmployeeDocumentConnection, error) { - if err := r.authorize(ctx, organizationID, probo.ActionDocumentList); err != nil { + if err := r.authorize(ctx, organizationID, probo.ActionEmployeeDocumentList); err != nil { return nil, err } @@ -10222,7 +10233,7 @@ func (r *viewerResolver) SignableDocuments(ctx context.Context, obj *types.Viewe identity := authn.IdentityFromContext(ctx) - documentFilter := coredata.NewDocumentFilter(nil).WithUserEmail(&identity.EmailAddress) + documentFilter := coredata.NewDocumentFilter(nil).WithEmployeeIdentityID(&identity.ID, coredata.EmployeeFilterModeSignature) documentsPage, err := prb.Documents.ListByOrganizationID(ctx, organizationID, cursor, documentFilter) if err != nil { @@ -10249,7 +10260,7 @@ func (r *viewerResolver) SignableDocuments(ctx context.Context, obj *types.Viewe // SignableDocument is the resolver for the signableDocument field. func (r *viewerResolver) SignableDocument(ctx context.Context, obj *types.Viewer, id gid.GID) (*types.EmployeeDocument, error) { - if err := r.authorize(ctx, id, probo.ActionDocumentGet); err != nil { + if err := r.authorize(ctx, id, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -10257,7 +10268,7 @@ func (r *viewerResolver) SignableDocument(ctx context.Context, obj *types.Viewer identity := authn.IdentityFromContext(ctx) - documentFilter := coredata.NewDocumentFilter(nil).WithUserEmail(&identity.EmailAddress) + documentFilter := coredata.NewDocumentFilter(nil).WithEmployeeIdentityID(&identity.ID, coredata.EmployeeFilterModeSignature) document, err := prb.Documents.GetWithFilter(ctx, id, documentFilter) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { @@ -10280,7 +10291,7 @@ func (r *viewerResolver) SignableDocument(ctx context.Context, obj *types.Viewer // ApprovableDocuments is the resolver for the approvableDocuments field. func (r *viewerResolver) ApprovableDocuments(ctx context.Context, obj *types.Viewer, organizationID gid.GID, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DocumentOrderBy) (*types.EmployeeDocumentConnection, error) { - if err := r.authorize(ctx, organizationID, probo.ActionDocumentVersionApprovalList); err != nil { + if err := r.authorize(ctx, organizationID, probo.ActionEmployeeDocumentList); err != nil { return nil, err } @@ -10301,7 +10312,7 @@ func (r *viewerResolver) ApprovableDocuments(ctx context.Context, obj *types.Vie identity := authn.IdentityFromContext(ctx) - documentFilter := coredata.NewDocumentFilter(nil).WithApproverIdentityID(&identity.ID) + documentFilter := coredata.NewDocumentFilter(nil).WithEmployeeIdentityID(&identity.ID, coredata.EmployeeFilterModeApproval) documentsPage, err := prb.Documents.ListByOrganizationID(ctx, organizationID, cursor, documentFilter) if err != nil { @@ -10328,7 +10339,7 @@ func (r *viewerResolver) ApprovableDocuments(ctx context.Context, obj *types.Vie // ApprovableDocument is the resolver for the approvableDocument field. func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.Viewer, id gid.GID) (*types.EmployeeDocument, error) { - if err := r.authorize(ctx, id, probo.ActionDocumentGet); err != nil { + if err := r.authorize(ctx, id, probo.ActionEmployeeDocumentGet); err != nil { return nil, err } @@ -10336,7 +10347,7 @@ func (r *viewerResolver) ApprovableDocument(ctx context.Context, obj *types.View identity := authn.IdentityFromContext(ctx) - documentFilter := coredata.NewDocumentFilter(nil).WithApproverIdentityID(&identity.ID) + documentFilter := coredata.NewDocumentFilter(nil).WithEmployeeIdentityID(&identity.ID, coredata.EmployeeFilterModeApproval) document, err := prb.Documents.GetWithFilter(ctx, id, documentFilter) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) {