@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
115
e2e/console/compliance_framework_test.go
Normal file
115
e2e/console/compliance_framework_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
396
e2e/console/security_read_gap_owners_test.go
Normal file
396
e2e/console/security_read_gap_owners_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
457
e2e/console/security_read_gap_test.go
Normal file
457
e2e/console/security_read_gap_test.go
Normal file
@@ -0,0 +1,457 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
162
e2e/console/security_write_gap_test.go
Normal file
162
e2e/console/security_write_gap_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@@ -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: <applicabilityStatementId>) 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")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
131
e2e/mcp/security_tracker_category_test.go
Normal file
131
e2e/mcp/security_tracker_category_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user