diff --git a/e2e/console/asset_test.go b/e2e/console/asset_test.go index e686f0e55..0b80cb904 100644 --- a/e2e/console/asset_test.go +++ b/e2e/console/asset_test.go @@ -367,3 +367,159 @@ func TestAsset_Types(t *testing.T) { }) } } + +func TestAsset_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + profileID := factory.CreateUser(org1Owner) + + var createResult struct { + CreateAsset struct { + AssetEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"assetEdge"` + } `json:"createAsset"` + } + + err := org1Owner.Execute(` + mutation($input: CreateAssetInput!) { + createAsset(input: $input) { + assetEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": "Org1 Asset", + "amount": 1, + "ownerId": profileID, + "assetType": "VIRTUAL", + "dataTypesStored": "Test data", + }, + }, &createResult) + require.NoError(t, err) + + assetID := createResult.CreateAsset.AssetEdge.Node.ID + + t.Run("cannot read asset from another organization", func(t *testing.T) { + query := ` + query($id: ID!) { + node(id: $id) { + ... on Asset { + id + name + } + } + } + ` + + var result struct { + Node *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } + + err := org2Owner.Execute(query, map[string]any{"id": assetID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "asset") + }) + + t.Run("cannot update asset from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: UpdateAssetInput!) { + updateAsset(input: $input) { + asset { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": assetID, + "name": "Hijacked Asset", + }, + }) + require.Error(t, err, "Should not be able to update asset from another org") + }) + + t.Run("cannot delete asset from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteAssetInput!) { + deleteAsset(input: $input) { + deletedAssetId + } + } + `, map[string]any{ + "input": map[string]any{ + "assetId": assetID, + }, + }) + require.Error(t, err, "Should not be able to delete asset from another org") + }) + + t.Run("cannot create asset referencing an owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateAssetInput!) { + createAsset(input: $input) { + assetEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("Asset"), + "amount": 1, + "ownerId": org2ProfileID, + "assetType": "VIRTUAL", + "dataTypesStored": "Test data", + }, + }) + require.Error(t, err, "must not accept an ownerId belonging to another organization") + }) + + t.Run("cannot create asset referencing a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty").Create() + + _, err := org1Owner.Do(` + mutation($input: CreateAssetInput!) { + createAsset(input: $input) { + assetEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("Asset"), + "amount": 1, + "ownerId": profileID, + "assetType": "VIRTUAL", + "dataTypesStored": "Test data", + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) + + t.Run("cannot update asset to reference a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty for Update").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateAssetInput!) { + updateAsset(input: $input) { + asset { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": assetID, + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) +} diff --git a/e2e/console/compliance_framework_test.go b/e2e/console/compliance_framework_test.go new file mode 100644 index 000000000..dbfc14a3c --- /dev/null +++ b/e2e/console/compliance_framework_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// 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/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +// trustCenterID looks up the caller's own organization's trust center id. +func trustCenterID(t *testing.T, c *testutil.Client) string { + t.Helper() + + var result struct { + Node struct { + TrustCenter struct { + ID string `json:"id"` + } `json:"trustCenter"` + } `json:"node"` + } + + err := c.Execute(` + query($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + trustCenter { id } + } + } + } + `, map[string]any{ + "organizationId": c.GetOrganizationID().String(), + }, &result) + require.NoError(t, err) + require.NotEmpty(t, result.Node.TrustCenter.ID) + + return result.Node.TrustCenter.ID +} + +func TestComplianceFramework_Create(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + trustCenterID := trustCenterID(t, owner) + frameworkID := factory.CreateFramework(owner) + + var result struct { + CreateComplianceFramework struct { + ComplianceFrameworkEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"complianceFrameworkEdge"` + } `json:"createComplianceFramework"` + } + + err := owner.Execute(` + mutation($input: CreateComplianceFrameworkInput!) { + createComplianceFramework(input: $input) { + complianceFrameworkEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "trustCenterId": trustCenterID, + "frameworkId": frameworkID, + }, + }, &result) + require.NoError(t, err) + require.NotEmpty(t, result.CreateComplianceFramework.ComplianceFrameworkEdge.Node.ID) +} + +// TestComplianceFramework_TenantIsolation covers GHSA-c74x-79w6-63jh's +// structural sibling: ComplianceFrameworkService.Create must not accept a +// frameworkId belonging to another organization -- the FK is tenant-agnostic +// (ON DELETE CASCADE) so a cross-tenant reference would let org A pin a link +// to org B's framework and would let org B silently cascade-delete org A's +// compliance page entry. +func TestComplianceFramework_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1TrustCenterID := trustCenterID(t, org1Owner) + org2FrameworkID := factory.CreateFramework(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateComplianceFrameworkInput!) { + createComplianceFramework(input: $input) { + complianceFrameworkEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "trustCenterId": org1TrustCenterID, + "frameworkId": org2FrameworkID, + }, + }) + require.Error(t, err, "must not accept a frameworkId belonging to another organization") +} diff --git a/e2e/console/control_test.go b/e2e/console/control_test.go index 497b2fb14..a493e54ad 100644 --- a/e2e/console/control_test.go +++ b/e2e/console/control_test.go @@ -889,3 +889,67 @@ func TestControl_SubResolvers(t *testing.T) { assert.GreaterOrEqual(t, len(result.Node.Measures.Edges), 1) }) } + +func TestControl_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + frameworkID := factory.CreateFramework(org1Owner) + controlID := factory.CreateControl(org1Owner, frameworkID) + + t.Run("cannot read control from another organization", func(t *testing.T) { + query := ` + query($id: ID!) { + node(id: $id) { + ... on Control { + id + name + } + } + } + ` + + var result struct { + Node *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } + + err := org2Owner.Execute(query, map[string]any{"id": controlID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "control") + }) + + t.Run("cannot update control from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: UpdateControlInput!) { + updateControl(input: $input) { + control { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": controlID, + "name": "Hijacked Control", + }, + }) + require.Error(t, err, "Should not be able to update control from another org") + }) + + t.Run("cannot delete control from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteControlInput!) { + deleteControl(input: $input) { + deletedControlId + } + } + `, map[string]any{ + "input": map[string]any{ + "controlId": controlID, + }, + }) + require.Error(t, err, "Should not be able to delete control from another org") + }) +} diff --git a/e2e/console/datum_test.go b/e2e/console/datum_test.go index f4e2b197a..575cfb34c 100644 --- a/e2e/console/datum_test.go +++ b/e2e/console/datum_test.go @@ -1353,6 +1353,46 @@ func TestDatum_TenantIsolation(t *testing.T) { } } }) + + t.Run("cannot create datum referencing a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty").Create() + + _, err := org1Owner.Do(` + mutation($input: CreateDatumInput!) { + createDatum(input: $input) { + datumEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("Datum"), + "dataClassification": "CONFIDENTIAL", + "ownerId": profileID, + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) + + t.Run("cannot update datum to reference a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty for Update").Create() + otherDatumID := factory.NewDatum(org1Owner, profileID).WithName("Org1 Datum for ThirdPartyIDs").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateDatumInput!) { + updateDatum(input: $input) { + datum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": otherDatumID, + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) } func TestDatum_Ordering(t *testing.T) { diff --git a/e2e/console/document_test.go b/e2e/console/document_test.go index 17b0c63d6..c742f70ce 100644 --- a/e2e/console/document_test.go +++ b/e2e/console/document_test.go @@ -1296,6 +1296,68 @@ func TestDocument_TenantIsolation(t *testing.T) { } } }) + + t.Run("cannot create document referencing a default approver from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateDocumentInput!) { + createDocument(input: $input) { + documentEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "title": factory.SafeName("Document"), + "content": testutil.ProseMirrorTextDoc("Document content"), + "documentType": "POLICY", + "classification": "INTERNAL", + "defaultApproverIds": []string{org2ProfileID}, + }, + }) + require.Error(t, err, "must not accept a defaultApproverId belonging to another organization") + }) + + t.Run("cannot update document to reference a default approver from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + otherDocumentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document for DefaultApproverIDs").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateDocumentInput!) { + updateDocument(input: $input) { + document { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": otherDocumentID, + "defaultApproverIds": []string{org2ProfileID}, + }, + }) + require.Error(t, err, "must not accept a defaultApproverId belonging to another organization") + }) + + t.Run("cannot request approval referencing an approver from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + otherDocumentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document for ApproverIDs").Create() + + _, err := org1Owner.Do(` + mutation($input: PublishDocumentInput!) { + publishDocument(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "minor": false, + "documentId": otherDocumentID, + "approverIds": []string{org2ProfileID}, + "changelog": "Test changelog", + }, + }) + require.Error(t, err, "must not accept an approverId belonging to another organization") + }) } func TestDocument_Ordering(t *testing.T) { diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 8f816b194..9ba5be236 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -2281,3 +2281,34 @@ func TestDocumentVersion_SignArchivedDocumentFails(t *testing.T) { }, }) } + +func TestDocumentVersion_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + docID := factory.NewDocument(org1Owner).WithTitle("Org1 Document for Version Isolation").Create() + versionID := latestDocumentVersionID(t, org1Owner, docID) + + t.Run("cannot read documentVersion from another organization", func(t *testing.T) { + query := ` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + id + } + } + } + ` + + var result struct { + Node *struct { + ID string `json:"id"` + } `json:"node"` + } + + err := org2Owner.Execute(query, map[string]any{"id": versionID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "documentVersion") + }) +} diff --git a/e2e/console/finding_test.go b/e2e/console/finding_test.go index 8267e6fec..611a79197 100644 --- a/e2e/console/finding_test.go +++ b/e2e/console/finding_test.go @@ -842,3 +842,86 @@ func TestFinding_StatusAndPriorityValues(t *testing.T) { }, ) } + +// TestFinding_TenantIsolation covers GHSA-c74x-79w6-63jh: a finding must not +// be able to store a risk_id belonging to another organization, whether set +// on create or on a later update. Before the fix, FindingService.Create/ +// Update persisted an attacker-supplied riskId with no scoped ownership +// check, and findingResolver.Risk authorized the finding (caller's org) +// instead of the risk, letting the caller read another org's risk through +// the dataloader's NewScopeFromObjectID(riskID) scope. +func TestFinding_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2RiskID := factory.CreateRisk(org2Owner, factory.Attrs{"name": "Org2 Confidential Risk"}) + + createQuery := ` + mutation CreateFinding($input: CreateFindingInput!) { + createFinding(input: $input) { + findingEdge { + node { id } + } + } + } + ` + + t.Run("cannot create finding referencing a risk from another organization", func(t *testing.T) { + t.Parallel() + + _, err := org1Owner.Do(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "kind": "OBSERVATION", + "status": "OPEN", + "priority": "LOW", + "riskId": org2RiskID, + }, + }) + require.Error(t, err, "must not accept a riskId belonging to another organization") + }) + + t.Run("cannot update finding to reference a risk from another organization", func(t *testing.T) { + t.Parallel() + + var createResult struct { + CreateFinding struct { + FindingEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"findingEdge"` + } `json:"createFinding"` + } + + err := org1Owner.Execute(createQuery, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "kind": "OBSERVATION", + "status": "OPEN", + "priority": "LOW", + }, + }, &createResult) + require.NoError(t, err) + + findingID := createResult.CreateFinding.FindingEdge.Node.ID + + updateQuery := ` + mutation UpdateFinding($input: UpdateFindingInput!) { + updateFinding(input: $input) { + finding { id } + } + } + ` + + _, err = org1Owner.Do(updateQuery, map[string]any{ + "input": map[string]any{ + "id": findingID, + "riskId": org2RiskID, + }, + }) + require.Error(t, err, "must not accept a riskId belonging to another organization") + }) +} diff --git a/e2e/console/mapping_test.go b/e2e/console/mapping_test.go index 9c96de353..410fd87fa 100644 --- a/e2e/console/mapping_test.go +++ b/e2e/console/mapping_test.go @@ -933,3 +933,209 @@ func TestMeasureDocumentMapping_CreateDelete(t *testing.T) { require.NoError(t, err) }) } + +// The mapping mutations below link two independently-authored resources +// (e.g. controlId + measureId) together. Each is only safe because the +// underlying service loads BOTH ids in the caller's own scope before +// upserting the junction row (see e.g. ControlService.CreateMeasureMapping); +// an attacker supplying a valid GID from another organization on either +// side must be rejected. These tests pin that invariant for every mapping +// mutation. + +func TestControlMeasureMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + frameworkID := factory.CreateFramework(org1Owner) + controlID := factory.CreateControl(org1Owner, frameworkID) + org2MeasureID := factory.NewMeasure(org2Owner).Create() + + _, err := org1Owner.Do(` + mutation($input: CreateControlMeasureMappingInput!) { + createControlMeasureMapping(input: $input) { + controlEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "controlId": controlID, + "measureId": org2MeasureID, + }, + }) + require.Error(t, err, "must not link a control to a measure from another organization") +} + +func TestRiskMeasureMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + riskID := factory.CreateRisk(org1Owner) + org2MeasureID := factory.NewMeasure(org2Owner).Create() + + _, err := org1Owner.Do(` + mutation($input: CreateRiskMeasureMappingInput!) { + createRiskMeasureMapping(input: $input) { + riskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskId": riskID, + "measureId": org2MeasureID, + }, + }) + require.Error(t, err, "must not link a risk to a measure from another organization") +} + +func TestControlDocumentMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + frameworkID := factory.CreateFramework(org1Owner) + controlID := factory.CreateControl(org1Owner, frameworkID) + org2DocumentID := factory.NewDocument(org2Owner).Create() + + _, err := org1Owner.Do(` + mutation($input: CreateControlDocumentMappingInput!) { + createControlDocumentMapping(input: $input) { + controlEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "controlId": controlID, + "documentId": org2DocumentID, + }, + }) + require.Error(t, err, "must not link a control to a document from another organization") +} + +func TestControlAuditMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + frameworkID := factory.CreateFramework(org1Owner) + controlID := factory.CreateControl(org1Owner, frameworkID) + org2FrameworkID := factory.CreateFramework(org2Owner) + org2AuditID := factory.CreateAudit(org2Owner, org2FrameworkID) + + _, err := org1Owner.Do(` + mutation($input: CreateControlAuditMappingInput!) { + createControlAuditMapping(input: $input) { + controlEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "controlId": controlID, + "auditId": org2AuditID, + }, + }) + require.Error(t, err, "must not link a control to an audit from another organization") +} + +func TestRiskDocumentMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + riskID := factory.CreateRisk(org1Owner) + org2DocumentID := factory.NewDocument(org2Owner).Create() + + _, err := org1Owner.Do(` + mutation($input: CreateRiskDocumentMappingInput!) { + createRiskDocumentMapping(input: $input) { + riskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskId": riskID, + "documentId": org2DocumentID, + }, + }) + require.Error(t, err, "must not link a risk to a document from another organization") +} + +func TestRiskObligationMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + riskID := factory.CreateRisk(org1Owner) + + org2ProfileID := factory.CreateUser(org2Owner) + + var createObligationResult struct { + CreateObligation struct { + ObligationEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"obligationEdge"` + } `json:"createObligation"` + } + + err := org2Owner.Execute(` + mutation($input: CreateObligationInput!) { + createObligation(input: $input) { + obligationEdge { + node { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org2Owner.GetOrganizationID().String(), + "area": "Risk Management", + "requirement": "Org2 Obligation for Mapping Isolation", + "ownerId": org2ProfileID, + "status": "NON_COMPLIANT", + "type": "LEGAL", + }, + }, &createObligationResult) + require.NoError(t, err) + + org2ObligationID := createObligationResult.CreateObligation.ObligationEdge.Node.ID + + _, err = org1Owner.Do(` + mutation($input: CreateRiskObligationMappingInput!) { + createRiskObligationMapping(input: $input) { + riskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "riskId": riskID, + "obligationId": org2ObligationID, + }, + }) + require.Error(t, err, "must not link a risk to an obligation from another organization") +} + +func TestMeasureDocumentMapping_TenantIsolation(t *testing.T) { + t.Parallel() + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + measureID := factory.NewMeasure(org1Owner).Create() + org2DocumentID := factory.NewDocument(org2Owner).Create() + + _, err := org1Owner.Do(` + mutation($input: CreateMeasureDocumentMappingInput!) { + createMeasureDocumentMapping(input: $input) { + measureEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "measureId": measureID, + "documentId": org2DocumentID, + }, + }) + require.Error(t, err, "must not link a measure to a document from another organization") +} diff --git a/e2e/console/obligation_test.go b/e2e/console/obligation_test.go index 774d23079..ffb661072 100644 --- a/e2e/console/obligation_test.go +++ b/e2e/console/obligation_test.go @@ -362,3 +362,136 @@ func TestObligation_StatusValues(t *testing.T) { }) } } + +func TestObligation_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + profileID := factory.CreateUser(org1Owner) + + var createResult struct { + CreateObligation struct { + ObligationEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"obligationEdge"` + } `json:"createObligation"` + } + + err := org1Owner.Execute(` + mutation($input: CreateObligationInput!) { + createObligation(input: $input) { + obligationEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "area": "Risk Management", + "requirement": "Org1 Obligation", + "ownerId": profileID, + "status": "NON_COMPLIANT", + "type": "LEGAL", + }, + }, &createResult) + require.NoError(t, err) + + obligationID := createResult.CreateObligation.ObligationEdge.Node.ID + + t.Run("cannot read obligation from another organization", func(t *testing.T) { + query := ` + query($id: ID!) { + node(id: $id) { + ... on Obligation { + id + requirement + } + } + } + ` + + var result struct { + Node *struct { + ID string `json:"id"` + Requirement string `json:"requirement"` + } `json:"node"` + } + + err := org2Owner.Execute(query, map[string]any{"id": obligationID}, &result) + testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "obligation") + }) + + t.Run("cannot update obligation from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: UpdateObligationInput!) { + updateObligation(input: $input) { + obligation { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": obligationID, + "area": "Hijacked Obligation", + }, + }) + require.Error(t, err, "Should not be able to update obligation from another org") + }) + + t.Run("cannot delete obligation from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteObligationInput!) { + deleteObligation(input: $input) { + deletedObligationId + } + } + `, map[string]any{ + "input": map[string]any{ + "obligationId": obligationID, + }, + }) + require.Error(t, err, "Should not be able to delete obligation from another org") + }) + + t.Run("cannot create obligation referencing an owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateObligationInput!) { + createObligation(input: $input) { + obligationEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "area": "Risk Management", + "requirement": factory.SafeName("Obligation"), + "ownerId": org2ProfileID, + "status": "NON_COMPLIANT", + "type": "LEGAL", + }, + }) + require.Error(t, err, "must not accept an ownerId belonging to another organization") + }) + + t.Run("cannot update obligation to reference an owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: UpdateObligationInput!) { + updateObligation(input: $input) { + obligation { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": obligationID, + "ownerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept an ownerId belonging to another organization") + }) +} diff --git a/e2e/console/processing_activity_test.go b/e2e/console/processing_activity_test.go index 1441cef90..0a3ce15c2 100644 --- a/e2e/console/processing_activity_test.go +++ b/e2e/console/processing_activity_test.go @@ -1146,6 +1146,108 @@ func TestProcessingActivity_TenantIsolation(t *testing.T) { } } }) + + // GHSA-c74x-79w6-63jh: a processing activity must not be able to store a + // dataProtectionOfficerId belonging to another organization, whether set + // on create or on a later update. Before the fix, + // ProcessingActivityService.Create/Update persisted an attacker-supplied + // profile id with no scoped ownership check, and + // processingActivityResolver.DataProtectionOfficer authorized the + // processing activity (caller's org) instead of the profile, letting the + // caller read another org's member PII through the dataloader's + // NewScopeFromObjectID(profileID) scope. + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Confidential DPO"}) + + t.Run("cannot create processing activity referencing a data protection officer from another organization", func(t *testing.T) { + query := ` + mutation CreateProcessingActivity($input: CreateProcessingActivityInput!) { + createProcessingActivity(input: $input) { + processingActivityEdge { + node { id } + } + } + } + ` + + _, err := org1Owner.Do(query, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("ProcessingActivity"), + "specialOrCriminalData": "NO", + "lawfulBasis": "CONSENT", + "internationalTransfers": false, + "dataProtectionImpactAssessmentNeeded": "NOT_NEEDED", + "transferImpactAssessmentNeeded": "NOT_NEEDED", + "role": "CONTROLLER", + "dataProtectionOfficerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept a dataProtectionOfficerId belonging to another organization") + }) + + t.Run("cannot update processing activity to reference a data protection officer from another organization", func(t *testing.T) { + otherPaID := factory.NewProcessingActivity(org1Owner).WithName("Org1 PA Without DPO").Create() + + query := ` + mutation UpdateProcessingActivity($input: UpdateProcessingActivityInput!) { + updateProcessingActivity(input: $input) { + processingActivity { id } + } + } + ` + + _, err := org1Owner.Do(query, map[string]any{ + "input": map[string]any{ + "id": otherPaID, + "dataProtectionOfficerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept a dataProtectionOfficerId belonging to another organization") + }) + + t.Run("cannot create processing activity referencing a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty").Create() + + _, err := org1Owner.Do(` + mutation($input: CreateProcessingActivityInput!) { + createProcessingActivity(input: $input) { + processingActivityEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("ProcessingActivity"), + "specialOrCriminalData": "NO", + "lawfulBasis": "CONSENT", + "internationalTransfers": false, + "dataProtectionImpactAssessmentNeeded": "NOT_NEEDED", + "transferImpactAssessmentNeeded": "NOT_NEEDED", + "role": "CONTROLLER", + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) + + t.Run("cannot update processing activity to reference a thirdParty from another organization", func(t *testing.T) { + org2ThirdPartyID := factory.NewThirdParty(org2Owner).WithName("Org2 ThirdParty for Update").Create() + otherPaID := factory.NewProcessingActivity(org1Owner).WithName("Org1 PA Without ThirdParties").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateProcessingActivityInput!) { + updateProcessingActivity(input: $input) { + processingActivity { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": otherPaID, + "thirdPartyIds": []string{org2ThirdPartyID}, + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) } func TestProcessingActivity_Ordering(t *testing.T) { diff --git a/e2e/console/risk_test.go b/e2e/console/risk_test.go index f44b61ce7..062c58af8 100644 --- a/e2e/console/risk_test.go +++ b/e2e/console/risk_test.go @@ -1020,6 +1020,48 @@ func TestRisk_TenantIsolation(t *testing.T) { }) require.Error(t, err, "Should not be able to delete risk from another org") }) + + t.Run("cannot create risk referencing an owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateRiskInput!) { + createRisk(input: $input) { + riskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("Risk"), + "category": "OPERATIONAL", + "treatment": "MITIGATED", + "inherentLikelihood": 2, + "inherentImpact": 2, + "ownerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept an ownerId belonging to another organization") + }) + + t.Run("cannot update risk to reference an owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + otherRiskID := factory.NewRisk(org1Owner).WithName("Org1 Risk for OwnerID").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateRiskInput!) { + updateRisk(input: $input) { + risk { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": otherRiskID, + "ownerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept an ownerId belonging to another organization") + }) } func TestRisk_LikelihoodImpactValues(t *testing.T) { diff --git a/e2e/console/security_read_gap_owners_test.go b/e2e/console/security_read_gap_owners_test.go new file mode 100644 index 000000000..829150e50 --- /dev/null +++ b/e2e/console/security_read_gap_owners_test.go @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// 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. + +// These tests extend the GHSA-c74x-79w6-63jh read-path regression coverage to +// every parent-authorized Profile field resolver. The advisory's confirmed +// exploit #2 disclosed another organization's person PII through +// processingActivity.dataProtectionOfficer; the same wrong-object +// authorization shape (authorizing the parent obj.ID with the child's +// ActionMembershipProfileGet, then loading the child through the scope-by-key +// Profile dataloader) also existed on asset.owner, datum.owner, finding.owner, +// obligation.owner, risk.owner, task.assignedTo, thirdParty.businessOwner and +// thirdParty.securityOwner. Each of those write paths validates the owner FK +// today, so these tests use injectCrossTenantFK to plant a foreign profile id +// directly in the row -- proving the read resolver now authorizes the actual +// child profile id and refuses cross-tenant PII independently of the write +// check (a future write regression, migration bug, or direct DB access). +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestSecurity_ReadGap_AssetOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ProfileID := factory.CreateUser(org1Owner) + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Asset Owner (read-gap probe)"}) + + var createResult struct { + CreateAsset struct { + AssetEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"assetEdge"` + } `json:"createAsset"` + } + + err := org1Owner.Execute(` + mutation($input: CreateAssetInput!) { + createAsset(input: $input) { + assetEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": "Org1 Asset for read-gap probe", + "amount": 1, + "ownerId": org1ProfileID, + "assetType": "VIRTUAL", + "dataTypesStored": "Test data", + }, + }, &createResult) + require.NoError(t, err) + + assetID := createResult.CreateAsset.AssetEdge.Node.ID + + injectCrossTenantFK(t, "assets", "owner_profile_id", assetID, org2ProfileID) + + var readResult struct { + Node struct { + Owner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Asset { + owner { id fullName } + } + } + } + `, map[string]any{"id": assetID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Owner == nil, "cross-tenant profile PII via asset.owner") +} + +func TestSecurity_ReadGap_DatumOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ProfileID := factory.CreateUser(org1Owner) + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Data Owner (read-gap probe)"}) + + datumID := factory.CreateDatum(org1Owner, org1ProfileID, factory.Attrs{"name": "Org1 Datum for read-gap probe"}) + + injectCrossTenantFK(t, "data", "owner_profile_id", datumID, org2ProfileID) + + var readResult struct { + Node struct { + Owner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Datum { + owner { id fullName } + } + } + } + `, map[string]any{"id": datumID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Owner == nil, "cross-tenant profile PII via datum.owner") +} + +func TestSecurity_ReadGap_FindingOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ProfileID := factory.CreateUser(org1Owner) + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Finding Owner (read-gap probe)"}) + + var createResult struct { + CreateFinding struct { + FindingEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"findingEdge"` + } `json:"createFinding"` + } + + err := org1Owner.Execute(` + mutation($input: CreateFindingInput!) { + createFinding(input: $input) { + findingEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "kind": "OBSERVATION", + "status": "OPEN", + "priority": "LOW", + "ownerId": org1ProfileID, + }, + }, &createResult) + require.NoError(t, err) + + findingID := createResult.CreateFinding.FindingEdge.Node.ID + + injectCrossTenantFK(t, "findings", "owner_id", findingID, org2ProfileID) + + var readResult struct { + Node struct { + Owner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Finding { + owner { id fullName } + } + } + } + `, map[string]any{"id": findingID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Owner == nil, "cross-tenant profile PII via finding.owner") +} + +func TestSecurity_ReadGap_ObligationOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ProfileID := factory.CreateUser(org1Owner) + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Obligation Owner (read-gap probe)"}) + + var createResult struct { + CreateObligation struct { + ObligationEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"obligationEdge"` + } `json:"createObligation"` + } + + err := org1Owner.Execute(` + mutation($input: CreateObligationInput!) { + createObligation(input: $input) { + obligationEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "area": "Data Protection", + "source": "GDPR Article 5", + "requirement": "Org1 obligation for read-gap probe", + "ownerId": org1ProfileID, + "status": "NON_COMPLIANT", + "type": "LEGAL", + }, + }, &createResult) + require.NoError(t, err) + + obligationID := createResult.CreateObligation.ObligationEdge.Node.ID + + injectCrossTenantFK(t, "obligations", "owner_profile_id", obligationID, org2ProfileID) + + var readResult struct { + Node struct { + Owner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Obligation { + owner { id fullName } + } + } + } + `, map[string]any{"id": obligationID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Owner == nil, "cross-tenant profile PII via obligation.owner") +} + +func TestSecurity_ReadGap_RiskOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Risk Owner (read-gap probe)"}) + + riskID := factory.CreateRisk(org1Owner, factory.Attrs{"name": "Org1 Risk for read-gap probe"}) + + injectCrossTenantFK(t, "risks", "owner_profile_id", riskID, org2ProfileID) + + var readResult struct { + Node struct { + Owner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"owner"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Risk { + owner { id fullName } + } + } + } + `, map[string]any{"id": riskID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Owner == nil, "cross-tenant profile PII via risk.owner") +} + +func TestSecurity_ReadGap_TaskAssignedTo(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Assignee (read-gap probe)"}) + + taskID := factory.CreateTask(org1Owner, nil, factory.Attrs{"name": "Org1 Task for read-gap probe"}) + + injectCrossTenantFK(t, "tasks", "assigned_to_profile_id", taskID, org2ProfileID) + + var readResult struct { + Node struct { + AssignedTo *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"assignedTo"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Task { + assignedTo { id fullName } + } + } + } + `, map[string]any{"id": taskID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.AssignedTo == nil, "cross-tenant profile PII via task.assignedTo") +} + +func TestSecurity_ReadGap_ThirdPartyBusinessOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Business Owner (read-gap probe)"}) + + thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"}) + + injectCrossTenantFK(t, "third_parties", "business_owner_profile_id", thirdPartyID, org2ProfileID) + + var readResult struct { + Node struct { + BusinessOwner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"businessOwner"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + businessOwner { id fullName } + } + } + } + `, map[string]any{"id": thirdPartyID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.BusinessOwner == nil, "cross-tenant profile PII via thirdParty.businessOwner") +} + +func TestSecurity_ReadGap_ThirdPartySecurityOwner(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Security Owner (read-gap probe)"}) + + thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"}) + + injectCrossTenantFK(t, "third_parties", "security_owner_profile_id", thirdPartyID, org2ProfileID) + + var readResult struct { + Node struct { + SecurityOwner *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"securityOwner"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + securityOwner { id fullName } + } + } + } + `, map[string]any{"id": thirdPartyID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.SecurityOwner == nil, "cross-tenant profile PII via thirdParty.securityOwner") +} diff --git a/e2e/console/security_read_gap_test.go b/e2e/console/security_read_gap_test.go new file mode 100644 index 000000000..fbc77bf66 --- /dev/null +++ b/e2e/console/security_read_gap_test.go @@ -0,0 +1,457 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// 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 ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" + "go.probo.inc/probo/internal/test" +) + +// injectCrossTenantFK bypasses the application entirely and writes a foreign +// key directly via SQL against the same Postgres database the e2e probod +// instance is running against. This simulates a cross-tenant reference that +// exists in the row despite the application's own write-time validation -- +// e.g. a future regression in that specific check, a data migration bug, or +// direct DB access -- so that the read-path fix (GHSA-c74x-79w6-63jh pattern +// 2: authorizing the actual child id, not the parent's) can be verified on +// its own, independently of whether the write-time check is still in place. +func injectCrossTenantFK(t *testing.T, table, column, rowID, foreignID string) { + t.Helper() + + client := test.PGClient(t) + ctx := context.Background() + + err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + _, err := conn.Exec(ctx, `UPDATE `+table+` SET `+column+` = $1 WHERE id = $2`, foreignID, rowID) + return err + }) + require.NoError(t, err, "test setup: cannot inject cross-tenant FK into %s.%s", table, column) +} + +// TestSecurity_ReadGap_FindingRisk independently verifies the fix for +// GHSA-c74x-79w6-63jh's confirmed exploit #1. findingResolver.Risk now +// authorizes obj.Risk.ID instead of obj.ID; this test proves that holds even +// when findings.risk_id points at another organization's risk by means other +// than FindingService.Create/Update (which is separately validated and +// covered by TestFinding_TenantIsolation). +func TestSecurity_ReadGap_FindingRisk(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2RiskID := factory.CreateRisk(org2Owner, factory.Attrs{"name": "Org2 Secret Risk (read-gap probe)"}) + + var createResult struct { + CreateFinding struct { + FindingEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"findingEdge"` + } `json:"createFinding"` + } + + err := org1Owner.Execute(` + mutation($input: CreateFindingInput!) { + createFinding(input: $input) { + findingEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "kind": "OBSERVATION", + "status": "OPEN", + "priority": "LOW", + }, + }, &createResult) + require.NoError(t, err) + + findingID := createResult.CreateFinding.FindingEdge.Node.ID + + injectCrossTenantFK(t, "findings", "risk_id", findingID, org2RiskID) + + var readResult struct { + Node struct { + Risk *struct { + ID string `json:"id"` + } `json:"risk"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Finding { + risk { id } + } + } + } + `, map[string]any{"id": findingID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Risk == nil, "cross-tenant risk via finding.risk") +} + +// TestSecurity_ReadGap_ProcessingActivityDataProtectionOfficer independently +// verifies the fix for GHSA-c74x-79w6-63jh's confirmed exploit #2 (PII +// disclosure). processingActivityResolver.DataProtectionOfficer now +// authorizes obj.DataProtectionOfficer.ID instead of obj.ID. +func TestSecurity_ReadGap_ProcessingActivityDataProtectionOfficer(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret DPO (read-gap probe)"}) + + paID := factory.NewProcessingActivity(org1Owner).WithName("Org1 PA for read-gap probe").Create() + + injectCrossTenantFK(t, "processing_activities", "dpo_profile_id", paID, org2ProfileID) + + var readResult struct { + Node struct { + DataProtectionOfficer *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"dataProtectionOfficer"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on ProcessingActivity { + dataProtectionOfficer { id fullName } + } + } + } + `, map[string]any{"id": paID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.DataProtectionOfficer == nil, "cross-tenant profile PII via processingActivity.dataProtectionOfficer") +} + +// TestSecurity_ReadGap_AuditFramework independently verifies the +// defense-in-depth fix for auditResolver.Framework, which now authorizes +// obj.Framework.ID instead of obj.ID. +func TestSecurity_ReadGap_AuditFramework(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1FrameworkID := factory.CreateFramework(org1Owner) + auditID := factory.CreateAudit(org1Owner, org1FrameworkID) + + org2FrameworkID := factory.CreateFramework(org2Owner, factory.Attrs{"name": "Org2 Secret Framework (read-gap probe)"}) + + injectCrossTenantFK(t, "audits", "framework_id", auditID, org2FrameworkID) + + var readResult struct { + Node struct { + Framework *struct { + ID string `json:"id"` + } `json:"framework"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Audit { + framework { id } + } + } + } + `, map[string]any{"id": auditID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.Framework == nil, "cross-tenant framework via audit.framework") +} + +// TestSecurity_ReadGap_ApplicabilityStatementControl independently verifies +// the defense-in-depth fix for applicabilityStatementResolver.Control, which +// now authorizes obj.Control.ID instead of obj.ID. +func TestSecurity_ReadGap_ApplicabilityStatementControl(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + soaID := factory.NewStatementOfApplicability(org1Owner).Create() + org1FrameworkID := factory.CreateFramework(org1Owner) + org1ControlID := factory.CreateControl(org1Owner, org1FrameworkID) + asID := factory.CreateApplicabilityStatement(org1Owner, soaID, org1ControlID, true, nil) + + org2FrameworkID := factory.CreateFramework(org2Owner) + org2ControlID := factory.CreateControl(org2Owner, org2FrameworkID, factory.Attrs{"name": "Org2 Secret Control (read-gap probe)"}) + + injectCrossTenantFK(t, "applicability_statements", "control_id", asID, org2ControlID) + + // ApplicabilityStatement is not reachable via node(id:...) directly (a + // separate, unrelated gap: coredata.ApplicabilityStatementEntityType has + // no case in queryResolver.Node's dispatch switch, so it always denies + // with an empty action regardless of tenant). Reach the same + // applicabilityStatementResolver.Control sub-resolver through + // StatementOfApplicability.applicabilityStatements instead, which does + // implement Node. + var readResult struct { + Node struct { + ApplicabilityStatements struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Control *struct { + ID string `json:"id"` + } `json:"control"` + } `json:"node"` + } `json:"edges"` + } `json:"applicabilityStatements"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on StatementOfApplicability { + applicabilityStatements(first: 10) { + edges { + node { + id + control { id } + } + } + } + } + } + } + `, map[string]any{"id": soaID}, &readResult) + + // control: Control! is non-nullable, so a denial on that field nulls the + // whole ancestor chain up to the query's nullable "node" root -- err != nil + // with no edges IS the expected block, same as AssertNodeNotAccessible's + // contract elsewhere in this suite. + if err == nil { + var got *struct { + ID string `json:"id"` + } + for _, edge := range readResult.Node.ApplicabilityStatements.Edges { + if edge.Node.ID == asID { + got = edge.Node.Control + } + } + require.Nil(t, got, "must not be able to read a cross-tenant control via applicabilityStatement.control") + } +} + +// TestSecurity_ReadGap_DocumentVersionSignatureSignedBy independently +// verifies the defense-in-depth fix for documentVersionSignatureResolver.SignedBy, +// which now authorizes obj.SignedBy.ID instead of obj.ID. +func TestSecurity_ReadGap_DocumentVersionSignatureSignedBy(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, org1Owner) + approveTestDocument(t, org1Owner, docID) + versionID := latestDocumentVersionID(t, org1Owner, docID) + org1ProfileID := getOwnerProfileID(t, org1Owner) + + var sigResult struct { + RequestSignature struct { + DocumentVersionSignatureEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentVersionSignatureEdge"` + } `json:"requestSignature"` + } + + err := org1Owner.Execute(` + mutation($input: RequestSignatureInput!) { + requestSignature(input: $input) { + documentVersionSignatureEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + "signatoryId": org1ProfileID, + }, + }, &sigResult) + require.NoError(t, err) + + signatureID := sigResult.RequestSignature.DocumentVersionSignatureEdge.Node.ID + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Signatory (read-gap probe)"}) + + injectCrossTenantFK(t, "document_version_signatures", "signed_by_profile_id", signatureID, org2ProfileID) + + var readResult struct { + Node struct { + SignedBy *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"signedBy"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersionSignature { + signedBy { id fullName } + } + } + } + `, map[string]any{"id": signatureID}, &readResult) + + testutil.AssertNodeNotAccessible(t, err, readResult.Node.SignedBy == nil, "cross-tenant profile PII via documentVersionSignature.signedBy") +} + +// TestSecurity_ReadGap_DocumentVersionApprovalDecisionApprover independently +// verifies a read-gap found while auditing GHSA-c74x-79w6-63jh's blast +// radius: documentVersionApprovalDecisionResolver.Approver authorized +// obj.ID (the decision, caller's own org) instead of obj.Approver.ID, and +// the profile loader it called into (iam.OrganizationService.GetProfile) +// derives its scope from coredata.NewScopeFromObjectID(profileID) -- the +// exact same scope-from-attacker-key mechanism as the two confirmed GHSA +// exploits. Not independently exploitable today because the write path +// (DocumentApprovalService.createDecisions, reached via +// validateApproverProfileIDs in document_service.go/generated_document_service.go) +// already rejects a cross-tenant approverId, but this proves the read-side +// fix (authorizing obj.Approver.ID) holds on its own. +func TestSecurity_ReadGap_DocumentVersionApprovalDecisionApprover(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, org1Owner) + org1ProfileID := getOwnerProfileID(t, org1Owner) + requestDocumentApproval(t, org1Owner, docID, []string{org1ProfileID}) + versionID := latestDocumentVersionID(t, org1Owner, docID) + + var quorumResult struct { + Node struct { + ApprovalQuorums struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Decisions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"decisions"` + } `json:"node"` + } `json:"edges"` + } `json:"approvalQuorums"` + } `json:"node"` + } + + err := org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + approvalQuorums(first: 10) { + edges { + node { + id + decisions(first: 10) { + edges { node { id } } + } + } + } + } + } + } + } + `, map[string]any{"id": versionID}, &quorumResult) + require.NoError(t, err) + require.NotEmpty(t, quorumResult.Node.ApprovalQuorums.Edges) + require.NotEmpty(t, quorumResult.Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges) + + decisionID := quorumResult.Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges[0].Node.ID + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Approver Profile"}) + + injectCrossTenantFK(t, "document_version_approval_decisions", "approver_id", decisionID, org2ProfileID) + + var readResult struct { + Node struct { + ApprovalQuorums struct { + Edges []struct { + Node struct { + Decisions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Approver *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } `json:"approver"` + } `json:"node"` + } `json:"edges"` + } `json:"decisions"` + } `json:"node"` + } `json:"edges"` + } `json:"approvalQuorums"` + } `json:"node"` + } + + err = org1Owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + approvalQuorums(first: 10) { + edges { + node { + decisions(first: 10) { + edges { node { id approver { id fullName } } } + } + } + } + } + } + } + } + `, map[string]any{"id": versionID}, &readResult) + + // approver: Profile! is non-nullable, so a denial on that field nulls the + // whole ancestor chain up to the query's nullable "node" root -- err != + // nil with no edges IS the expected block. + if err == nil { + var got *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + } + for _, qEdge := range readResult.Node.ApprovalQuorums.Edges { + for _, dEdge := range qEdge.Node.Decisions.Edges { + if dEdge.Node.ID == decisionID { + got = dEdge.Node.Approver + } + } + } + require.Nil(t, got, "must not be able to read a cross-tenant profile via documentVersionApprovalDecision.approver") + } +} diff --git a/e2e/console/security_write_gap_test.go b/e2e/console/security_write_gap_test.go new file mode 100644 index 000000000..e6a224209 --- /dev/null +++ b/e2e/console/security_write_gap_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// 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 ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" + "go.probo.inc/probo/internal/test" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// TestSecurity_WriteGap_PublishRiskListApproverIDs covers a write-gap found +// while auditing GHSA-c74x-79w6-63jh's blast radius: GeneratedDocumentService's +// shared publishOrRequestApproval helper (used by every generated-document +// Publish* method: risk list, processing activity list, third party list, +// obligation list, finding list, data list, DPIA/TIA lists, asset list, +// statement of applicability, framework/audit report) persisted +// caller-supplied approverIds as DocumentDefaultApprovers and +// DocumentVersionApprovalDecision rows without validating they belong to the +// caller's own organization. This test exercises one call site +// (publishRiskList); the fix (validateApproverProfileIDs) lives in the single +// shared helper so it covers all of them. +func TestSecurity_WriteGap_PublishRiskListApproverIDs(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + factory.CreateRisk(org1Owner, factory.Attrs{"name": "Org1 Risk for publish"}) + + org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Approver"}) + + _, err := org1Owner.Do(` + mutation($input: PublishRiskListInput!) { + publishRiskList(input: $input) { + documentEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "approverIds": []string{org2ProfileID}, + "minor": false, + }, + }) + require.Error(t, err, "must not accept an approverId belonging to another organization") +} + +// TestSecurity_WriteGap_TrustCenterAccessDocuments covers a write-gap found +// while auditing GHSA-c74x-79w6-63jh's blast radius: TrustCenterAccessService.Update +// persisted caller-supplied document/report-file/trust-center-file ids into +// trust_center_document_accesses (via coredata's MergeDocumentAccesses/ +// MergeReportFileAccesses/MergeTrustCenterFileAccesses) without validating +// they belong to the trust center's own organization -- the DB-level FK check +// alone doesn't catch this because those primary keys are globally unique, +// not per-tenant. +// +// TrustCenterAccess rows are normally created through the trust/v1 public +// portal's visitor request flow (requestAllAccesses), which needs a +// separate authenticated visitor identity and NDA acceptance. To keep this +// test focused on the fix under test (the Update mutation's FK validation) +// rather than that unrelated flow, the access row's prerequisite state is +// seeded directly via SQL against the same Postgres database the e2e probod +// instance runs against, then the real updateTrustCenterAccess mutation is +// exercised through the live GraphQL API. +func TestSecurity_WriteGap_TrustCenterAccessDocuments(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1TrustCenterID := trustCenterID(t, org1Owner) + org1DocumentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document for trust center access").Create() + org2DocumentID := factory.NewDocument(org2Owner).WithTitle("Org2 Secret Document").Create() + + accessID := seedTrustCenterAccess(t, org1Owner, org1TrustCenterID) + + t.Run("cannot grant access to a document from another organization", func(t *testing.T) { + _, err := org1Owner.Do(` + mutation($input: UpdateTrustCenterAccessInput!) { + updateTrustCenterAccess(input: $input) { + trustCenterAccess { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": accessID, + "documents": []map[string]any{{"id": org2DocumentID, "status": "GRANTED"}}, + }, + }) + require.Error(t, err, "must not accept a documentId belonging to another organization") + }) + + t.Run("can grant access to a document from the same organization", func(t *testing.T) { + _, err := org1Owner.Do(` + mutation($input: UpdateTrustCenterAccessInput!) { + updateTrustCenterAccess(input: $input) { + trustCenterAccess { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": accessID, + "documents": []map[string]any{{"id": org1DocumentID, "status": "GRANTED"}}, + }, + }) + require.NoError(t, err) + }) +} + +// seedTrustCenterAccess inserts a minimal trust_center_accesses row directly +// via SQL, bypassing the trust/v1 visitor request flow (which requires a +// separate authenticated visitor identity and NDA acceptance) so that +// updateTrustCenterAccess -- the mutation under test -- can be exercised in +// isolation. owner's own identity id is reused to satisfy the row's +// identity_id foreign key; which identity it is doesn't matter for this test. +func seedTrustCenterAccess(t *testing.T, owner *testutil.Client, trustCenterID string) string { + t.Helper() + + tcID, err := gid.ParseGID(trustCenterID) + require.NoError(t, err) + + tenantID := owner.GetOrganizationID().TenantID() + accessID := gid.New(tenantID, coredata.TrustCenterAccessEntityType) + now := time.Now().UTC() + + client := test.PGClient(t) + ctx := context.Background() + + err = client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + _, err := conn.Exec(ctx, ` + INSERT INTO trust_center_accesses (id, tenant_id, organization_id, trust_center_id, identity_id, email, name, state, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'ACTIVE', $8, $8) + `, + accessID.String(), tenantID.String(), owner.GetOrganizationID().String(), tcID.String(), + owner.GetUserID().String(), factory.SafeEmail(), "Test Access", now, + ) + return err + }) + require.NoError(t, err, "test setup: cannot seed trust_center_accesses row") + + return accessID.String() +} diff --git a/e2e/console/statement_of_applicability_test.go b/e2e/console/statement_of_applicability_test.go index 6c79ca616..0e061114c 100644 --- a/e2e/console/statement_of_applicability_test.go +++ b/e2e/console/statement_of_applicability_test.go @@ -23,6 +23,46 @@ import ( "go.probo.inc/probo/e2e/internal/testutil" ) +// TestApplicabilityStatement_NodeQuery covers a bug found while writing +// read-gap regression tests for GHSA-c74x-79w6-63jh: coredata.ApplicabilityStatementEntityType +// had no case in queryResolver.Node's dispatch switch (base_resolvers.go), +// so node(id: ) always failed with an empty +// authorize action, for any caller regardless of org -- ApplicabilityStatement +// implements Node in the schema but was unreachable through it. +func TestApplicabilityStatement_NodeQuery(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + soaID := factory.NewStatementOfApplicability(owner).Create() + frameworkID := factory.CreateFramework(owner) + controlID := factory.CreateControl(owner, frameworkID) + asID := factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil) + + var result struct { + Node *struct { + ID string `json:"id"` + Control struct { + ID string `json:"id"` + } `json:"control"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on ApplicabilityStatement { + id + control { id } + } + } + } + `, map[string]any{"id": asID}, &result) + require.NoError(t, err) + require.NotNil(t, result.Node) + assert.Equal(t, asID, result.Node.ID) + assert.Equal(t, controlID, result.Node.Control.ID) +} + func TestStatementOfApplicability_Create(t *testing.T) { t.Parallel() @@ -756,4 +796,29 @@ func TestStatementOfApplicability_TenantIsolation(t *testing.T) { require.Error(t, err) }, ) + + t.Run( + "cannot create applicability statement referencing a control from another organization", + func(t *testing.T) { + t.Parallel() + + org2FrameworkID := factory.CreateFramework(org2Owner) + org2ControlID := factory.CreateControl(org2Owner, org2FrameworkID) + + _, err := org1Owner.Do(` + mutation($input: CreateApplicabilityStatementInput!) { + createApplicabilityStatement(input: $input) { + applicabilityStatementEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "statementOfApplicabilityId": soaID, + "controlId": org2ControlID, + "applicability": true, + }, + }) + require.Error(t, err, "must not accept a controlId belonging to another organization") + }, + ) } diff --git a/e2e/console/task_test.go b/e2e/console/task_test.go index c4077b7ed..068b84c65 100644 --- a/e2e/console/task_test.go +++ b/e2e/console/task_test.go @@ -924,4 +924,83 @@ func TestTask_TenantIsolation(t *testing.T) { }) require.Error(t, err, "Should not be able to delete task from another org") }) + + t.Run("cannot create task referencing a measure from another organization", func(t *testing.T) { + org2MeasureID := factory.NewMeasure(org2Owner).WithName("Org2 Measure").Create() + + _, err := org1Owner.Do(` + mutation($input: CreateTaskInput!) { + createTask(input: $input) { + taskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "measureId": org2MeasureID, + "name": factory.SafeName("Task"), + "priority": "MEDIUM", + }, + }) + require.Error(t, err, "must not accept a measureId belonging to another organization") + }) + + t.Run("cannot create task referencing an assignee from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateTaskInput!) { + createTask(input: $input) { + taskEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "measureId": measureID, + "name": factory.SafeName("Task"), + "priority": "MEDIUM", + "assignedToId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept an assignedToId belonging to another organization") + }) + + t.Run("cannot update task to reference a measure from another organization", func(t *testing.T) { + org2MeasureID := factory.NewMeasure(org2Owner).WithName("Org2 Measure for Update").Create() + otherTaskID := factory.NewTask(org1Owner, measureID).WithName("Org1 Task for MeasureID").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateTaskInput!) { + updateTask(input: $input) { + task { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "taskId": otherTaskID, + "measureId": org2MeasureID, + }, + }) + require.Error(t, err, "must not accept a measureId belonging to another organization") + }) + + t.Run("cannot update task to reference an assignee from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + otherTaskID := factory.NewTask(org1Owner, measureID).WithName("Org1 Task for AssignedToID").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateTaskInput!) { + updateTask(input: $input) { + task { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "taskId": otherTaskID, + "assignedToId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept an assignedToId belonging to another organization") + }) } diff --git a/e2e/console/third_party_compliance_report_test.go b/e2e/console/third_party_compliance_report_test.go index 259d2d8db..fc7766da9 100644 --- a/e2e/console/third_party_compliance_report_test.go +++ b/e2e/console/third_party_compliance_report_test.go @@ -243,3 +243,93 @@ func TestThirdPartyComplianceReport_Delete(t *testing.T) { require.NoError(t, err) assert.Equal(t, reportID, deleteResult.DeleteThirdPartyComplianceReport.DeletedThirdPartyComplianceReportID) } + +func TestThirdPartyComplianceReport_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for Report").Create() + + pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF") + + var createResult struct { + UploadThirdPartyComplianceReport struct { + ThirdPartyComplianceReportEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"thirdPartyComplianceReportEdge"` + } `json:"uploadThirdPartyComplianceReport"` + } + + err := org1Owner.ExecuteWithFile( + ` + mutation($input: UploadThirdPartyComplianceReportInput!) { + uploadThirdPartyComplianceReport(input: $input) { + thirdPartyComplianceReportEdge { node { id } } + } + } + `, + map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "reportName": "Org1 Report", + "reportDate": "2024-01-01T00:00:00Z", + "file": nil, + }, + }, "input.file", testutil.UploadFile{ + Filename: "report.pdf", + ContentType: "application/pdf", + Content: pdfContent, + }, + &createResult, + ) + require.NoError(t, err) + + reportID := createResult.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node.ID + + t.Run("cannot delete thirdPartyComplianceReport from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteThirdPartyComplianceReportInput!) { + deleteThirdPartyComplianceReport(input: $input) { + deletedThirdPartyComplianceReportId + } + } + `, map[string]any{ + "input": map[string]any{ + "reportId": reportID, + }, + }) + require.Error(t, err, "Should not be able to delete thirdPartyComplianceReport from another org") + }) + + t.Run("cannot upload thirdPartyComplianceReport on a thirdParty from another organization", func(t *testing.T) { + var result struct{} + + err := org2Owner.ExecuteWithFile( + ` + mutation($input: UploadThirdPartyComplianceReportInput!) { + uploadThirdPartyComplianceReport(input: $input) { + thirdPartyComplianceReportEdge { node { id } } + } + } + `, + map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "reportName": "Attacker Report", + "reportDate": "2024-01-01T00:00:00Z", + "file": nil, + }, + }, "input.file", testutil.UploadFile{ + Filename: "attacker.pdf", + ContentType: "application/pdf", + Content: pdfContent, + }, + &result, + ) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) +} diff --git a/e2e/console/third_party_contact_test.go b/e2e/console/third_party_contact_test.go index cdebdd598..5f58c7482 100644 --- a/e2e/console/third_party_contact_test.go +++ b/e2e/console/third_party_contact_test.go @@ -291,3 +291,87 @@ func TestThirdPartyContact_List(t *testing.T) { require.NoError(t, err) assert.GreaterOrEqual(t, len(result.Node.Contacts.Edges), 3) } + +func TestThirdPartyContact_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for Contact").Create() + + var createResult struct { + CreateThirdPartyContact struct { + ThirdPartyContactEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"thirdPartyContactEdge"` + } `json:"createThirdPartyContact"` + } + + err := org1Owner.Execute(` + mutation($input: CreateThirdPartyContactInput!) { + createThirdPartyContact(input: $input) { + thirdPartyContactEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "fullName": "Org1 Contact", + "email": fmt.Sprintf("org1.contact.%d@thirdParty.com", time.Now().UnixNano()), + }, + }, &createResult) + require.NoError(t, err) + + contactID := createResult.CreateThirdPartyContact.ThirdPartyContactEdge.Node.ID + + t.Run("cannot update thirdPartyContact from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: UpdateThirdPartyContactInput!) { + updateThirdPartyContact(input: $input) { + thirdPartyContact { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": contactID, + "fullName": "Hijacked Contact", + }, + }) + require.Error(t, err, "Should not be able to update thirdPartyContact from another org") + }) + + t.Run("cannot delete thirdPartyContact from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteThirdPartyContactInput!) { + deleteThirdPartyContact(input: $input) { + deletedThirdPartyContactId + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyContactId": contactID, + }, + }) + require.Error(t, err, "Should not be able to delete thirdPartyContact from another org") + }) + + t.Run("cannot create thirdPartyContact on a thirdParty from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: CreateThirdPartyContactInput!) { + createThirdPartyContact(input: $input) { + thirdPartyContactEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "fullName": "Attacker Contact", + "email": fmt.Sprintf("attacker.%d@thirdParty.com", time.Now().UnixNano()), + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) +} diff --git a/e2e/console/third_party_service_test.go b/e2e/console/third_party_service_test.go index 9d3ec1ae8..7f410d97b 100644 --- a/e2e/console/third_party_service_test.go +++ b/e2e/console/third_party_service_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" "go.probo.inc/probo/e2e/internal/testutil" ) @@ -755,3 +756,85 @@ func TestThirdPartyService_List(t *testing.T) { }) } } + +func TestThirdPartyService_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + org1ThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for Service").Create() + + var createResult struct { + CreateThirdPartyService struct { + ThirdPartyServiceEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"thirdPartyServiceEdge"` + } `json:"createThirdPartyService"` + } + + err := org1Owner.Execute(` + mutation($input: CreateThirdPartyServiceInput!) { + createThirdPartyService(input: $input) { + thirdPartyServiceEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "name": "Org1 Service", + }, + }, &createResult) + require.NoError(t, err) + + serviceID := createResult.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID + + t.Run("cannot update thirdPartyService from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: UpdateThirdPartyServiceInput!) { + updateThirdPartyService(input: $input) { + thirdPartyService { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": serviceID, + "name": "Hijacked Service", + }, + }) + require.Error(t, err, "Should not be able to update thirdPartyService from another org") + }) + + t.Run("cannot delete thirdPartyService from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: DeleteThirdPartyServiceInput!) { + deleteThirdPartyService(input: $input) { + deletedThirdPartyServiceId + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyServiceId": serviceID, + }, + }) + require.Error(t, err, "Should not be able to delete thirdPartyService from another org") + }) + + t.Run("cannot create thirdPartyService on a thirdParty from another organization", func(t *testing.T) { + _, err := org2Owner.Do(` + mutation($input: CreateThirdPartyServiceInput!) { + createThirdPartyService(input: $input) { + thirdPartyServiceEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "thirdPartyId": org1ThirdPartyID, + "name": "Attacker Service", + }, + }) + require.Error(t, err, "must not accept a thirdPartyId belonging to another organization") + }) +} diff --git a/e2e/console/third_party_test.go b/e2e/console/third_party_test.go index 2020a656f..0749907d5 100644 --- a/e2e/console/third_party_test.go +++ b/e2e/console/third_party_test.go @@ -1171,4 +1171,61 @@ func TestThirdParty_TenantIsolation(t *testing.T) { }) require.Error(t, err, "Should not be able to delete thirdParty from another org") }) + + t.Run("cannot create thirdParty referencing a business owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + + _, err := org1Owner.Do(` + mutation($input: CreateThirdPartyInput!) { + createThirdParty(input: $input) { + thirdPartyEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("ThirdParty"), + "businessOwnerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept a businessOwnerId belonging to another organization") + }) + + t.Run("cannot update thirdParty to reference a security owner from another organization", func(t *testing.T) { + org2ProfileID := factory.CreateUser(org2Owner) + otherThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for SecurityOwner").Create() + + _, err := org1Owner.Do(` + mutation($input: UpdateThirdPartyInput!) { + updateThirdParty(input: $input) { + thirdParty { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": otherThirdPartyID, + "securityOwnerId": org2ProfileID, + }, + }) + require.Error(t, err, "must not accept a securityOwnerId belonging to another organization") + }) + + t.Run("cannot create thirdParty referencing a parent thirdParty from another organization", func(t *testing.T) { + org2ParentID := factory.NewThirdParty(org2Owner).WithName("Org2 Parent ThirdParty").Create() + + _, err := org1Owner.Do(` + mutation($input: CreateThirdPartyInput!) { + createThirdParty(input: $input) { + thirdPartyEdge { node { id } } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": org1Owner.GetOrganizationID().String(), + "name": factory.SafeName("ThirdParty"), + "parentThirdPartyId": org2ParentID, + }, + }) + require.Error(t, err, "must not accept a parentThirdPartyId belonging to another organization") + }) } diff --git a/e2e/mcp/security_tracker_category_test.go b/e2e/mcp/security_tracker_category_test.go new file mode 100644 index 000000000..742918581 --- /dev/null +++ b/e2e/mcp/security_tracker_category_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// 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 mcp_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +// createCookieBannerAndCategory creates a cookie banner and one category +// under it via the MCP API, returning the category id. +func createCookieBannerAndCategory(t *testing.T, mc *testutil.MCPClient, orgID string) string { + t.Helper() + + var bannerResult struct { + CookieBanner struct { + ID string `json:"id"` + } `json:"cookie_banner"` + } + mc.CallToolInto("addCookieBanner", map[string]any{ + "organizationId": orgID, + "name": factory.SafeName("CookieBanner"), + "origin": "https://example.com", + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + }, &bannerResult) + require.NotEmpty(t, bannerResult.CookieBanner.ID) + + var categoryResult struct { + CookieCategory struct { + ID string `json:"id"` + } `json:"cookie_category"` + } + mc.CallToolInto("addCookieCategory", map[string]any{ + "cookieBannerId": bannerResult.CookieBanner.ID, + "name": factory.SafeName("Category"), + "slug": factory.SafeName("category"), + "description": "Test category", + "rank": 1, + }, &categoryResult) + require.NotEmpty(t, categoryResult.CookieCategory.ID) + + return categoryResult.CookieCategory.ID +} + +// TestSecurity_MCP_MoveTrackerPatternToCategory_TenantIsolation covers a +// defense-in-depth gap found while auditing GHSA-c74x-79w6-63jh's blast +// radius: MoveTrackerPatternToCategoryTool (MCP) only authorized +// input.TrackerPatternID, never input.TargetCookieCategoryID, unlike the +// equivalent console/v1 GraphQL mutation which authorizes both. Not +// independently exploitable (pkg/cookiebanner.Service.MoveTrackerPatternToCategory +// loads the target category in the caller's own scope and asserts +// pattern.CookieBannerID == target.CookieBannerID), but hardened anyway and +// pinned here. +func TestSecurity_MCP_MoveTrackerPatternToCategory_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + org1MC := testutil.NewMCPClient(t, org1Owner) + org2MC := testutil.NewMCPClient(t, org2Owner) + + org1CategoryID := createCookieBannerAndCategory(t, org1MC, org1Owner.GetOrganizationID().String()) + org2CategoryID := createCookieBannerAndCategory(t, org2MC, org2Owner.GetOrganizationID().String()) + + var patternResult struct { + TrackerPattern struct { + ID string `json:"id"` + } `json:"tracker_pattern"` + } + org1MC.CallToolInto("addTrackerPattern", map[string]any{ + "cookieCategoryId": org1CategoryID, + "pattern": "org1-tracker", + "matchType": "EXACT", + "displayName": "Org1 Tracker", + }, &patternResult) + require.NotEmpty(t, patternResult.TrackerPattern.ID) + + errText := org1MC.CallToolExpectToolError("moveTrackerPatternToCategory", map[string]any{ + "trackerPatternId": patternResult.TrackerPattern.ID, + "targetCookieCategoryId": org2CategoryID, + }) + require.NotEmpty(t, errText, "must not accept a targetCookieCategoryId belonging to another organization") +} + +// TestSecurity_MCP_MoveTrackerResourceToCategory_TenantIsolation is the +// resource-side sibling of the pattern test above. +func TestSecurity_MCP_MoveTrackerResourceToCategory_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + org1MC := testutil.NewMCPClient(t, org1Owner) + org2MC := testutil.NewMCPClient(t, org2Owner) + + org1CategoryID := createCookieBannerAndCategory(t, org1MC, org1Owner.GetOrganizationID().String()) + org2CategoryID := createCookieBannerAndCategory(t, org2MC, org2Owner.GetOrganizationID().String()) + + var resourceResult struct { + TrackerResource struct { + ID string `json:"id"` + } `json:"tracker_resource"` + } + org1MC.CallToolInto("addTrackerResource", map[string]any{ + "cookieCategoryId": org1CategoryID, + "url": "https://org1.example.com/tracker.js", + "displayName": "Org1 Resource", + }, &resourceResult) + require.NotEmpty(t, resourceResult.TrackerResource.ID) + + errText := org1MC.CallToolExpectToolError("moveTrackerResourceToCategory", map[string]any{ + "trackerResourceId": resourceResult.TrackerResource.ID, + "targetCookieCategoryId": org2CategoryID, + }) + require.NotEmpty(t, errText, "must not accept a targetCookieCategoryId belonging to another organization") +}