Link measures to third parties

Add a many-to-many relationship between measures and third parties,
surfaced as a measures tab on the third party detail page and a third
parties tab on the measure detail page. Each side gets a paginated
list with a link/unlink dialog.

Also remove the right-hand drawer on the measure detail page and
expose the state as a badge in the page header, mirroring how the
compliance page surfaces its active flag.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-22 16:07:48 +02:00
parent b6b1e801b1
commit 6dfdd7ca49
35 changed files with 2109 additions and 54 deletions

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package linkthirdparty
import (
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const linkThirdPartyMutation = `
mutation($input: CreateMeasureThirdPartyMappingInput!) {
createMeasureThirdPartyMapping(input: $input) {
measureEdge {
node { id }
}
thirdPartyEdge {
node { id }
}
}
}
`
func NewCmdLinkThirdParty(f *cmdutil.Factory) *cobra.Command {
var (
flagMeasureID string
flagThirdPartyID string
)
cmd := &cobra.Command{
Use: "link-third-party",
Short: "Link a third party to a measure",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
linkThirdPartyMutation,
map[string]any{
"input": map[string]any{
"measureId": flagMeasureID,
"thirdPartyId": flagThirdPartyID,
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Linked third party %s to measure %s\n",
flagThirdPartyID,
flagMeasureID,
)
return nil
},
}
cmd.Flags().StringVar(&flagMeasureID, "measure-id", "", "Measure ID (required)")
cmd.Flags().StringVar(&flagThirdPartyID, "third-party-id", "", "Third party ID (required)")
_ = cmd.MarkFlagRequired("measure-id")
_ = cmd.MarkFlagRequired("third-party-id")
return cmd
}

View File

@@ -19,7 +19,9 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/measure/create"
"go.probo.inc/probo/pkg/cmd/measure/delete"
linkthirdparty "go.probo.inc/probo/pkg/cmd/measure/link-third-party"
"go.probo.inc/probo/pkg/cmd/measure/list"
unlinkthirdparty "go.probo.inc/probo/pkg/cmd/measure/unlink-third-party"
"go.probo.inc/probo/pkg/cmd/measure/update"
"go.probo.inc/probo/pkg/cmd/measure/view"
)
@@ -35,6 +37,8 @@ func NewCmdMeasure(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(linkthirdparty.NewCmdLinkThirdParty(f))
cmd.AddCommand(unlinkthirdparty.NewCmdUnlinkThirdParty(f))
return cmd
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package unlinkthirdparty
import (
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const unlinkThirdPartyMutation = `
mutation($input: DeleteMeasureThirdPartyMappingInput!) {
deleteMeasureThirdPartyMapping(input: $input) {
deletedMeasureId
deletedThirdPartyId
}
}
`
func NewCmdUnlinkThirdParty(f *cmdutil.Factory) *cobra.Command {
var (
flagMeasureID string
flagThirdPartyID string
)
cmd := &cobra.Command{
Use: "unlink-third-party",
Short: "Unlink a third party from a measure",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
unlinkThirdPartyMutation,
map[string]any{
"input": map[string]any{
"measureId": flagMeasureID,
"thirdPartyId": flagThirdPartyID,
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Unlinked third party %s from measure %s\n",
flagThirdPartyID,
flagMeasureID,
)
return nil
},
}
cmd.Flags().StringVar(&flagMeasureID, "measure-id", "", "Measure ID (required)")
cmd.Flags().StringVar(&flagThirdPartyID, "third-party-id", "", "Third party ID (required)")
_ = cmd.MarkFlagRequired("measure-id")
_ = cmd.MarkFlagRequired("third-party-id")
return cmd
}

View File

@@ -720,3 +720,115 @@ WHERE %s
return err
}
func (m *Measures) CountByThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyID gid.GID,
filter *MeasureFilter,
) (int, error) {
q := `
WITH mtgtns AS (
SELECT
m.id,
m.tenant_id,
m.search_vector,
m.state,
m.category
FROM
measures m
INNER JOIN
measures_third_parties mtp ON m.id = mtp.measure_id
WHERE
mtp.third_party_id = @third_party_id
)
SELECT
COUNT(id)
FROM
mtgtns
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.NamedArgs{"third_party_id": thirdPartyID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (m *Measures) LoadByThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyID gid.GID,
cursor *page.Cursor[MeasureOrderField],
filter *MeasureFilter,
) error {
q := `
WITH mtgtns AS (
SELECT
m.id,
m.tenant_id,
m.organization_id,
m.category,
m.name,
m.description,
m.state,
m.reference_id,
m.search_vector,
m.created_at,
m.updated_at
FROM
measures m
INNER JOIN
measures_third_parties mtp ON m.id = mtp.measure_id
WHERE
mtp.third_party_id = @third_party_id
)
SELECT
id,
organization_id,
category,
name,
description,
state,
reference_id,
created_at,
updated_at
FROM
mtgtns
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"third_party_id": thirdPartyID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query measures: %w", err)
}
measures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Measure])
if err != nil {
return fmt.Errorf("cannot collect measures: %w", err)
}
*m = measures
return nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
MeasureThirdParty struct {
MeasureID gid.GID `db:"measure_id"`
ThirdPartyID gid.GID `db:"third_party_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
MeasureThirdParties []*MeasureThirdParty
)
// Upsert links a measure to a third party. The organization_id stored in the
// junction row is derived from the measures table inside the INSERT, so a
// caller cannot place the mapping into a different organization than the
// measure actually belongs to. Idempotent: re-linking an existing pair is a
// no-op.
func (mtp MeasureThirdParty) Upsert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO
measures_third_parties (
measure_id,
third_party_id,
organization_id,
tenant_id,
created_at
)
SELECT
@measure_id,
@third_party_id,
m.organization_id,
@tenant_id,
@created_at
FROM
measures m
WHERE
m.id = @measure_id
AND m.tenant_id = @tenant_id
ON CONFLICT (measure_id, third_party_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"measure_id": mtp.MeasureID,
"third_party_id": mtp.ThirdPartyID,
"tenant_id": scope.GetTenantID(),
"created_at": mtp.CreatedAt,
}
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot upsert measure third party: %w", err)
}
return nil
}
func (mtp MeasureThirdParty) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
measureID gid.GID,
thirdPartyID gid.GID,
) error {
q := `
DELETE
FROM
measures_third_parties
WHERE
%s
AND measure_id = @measure_id
AND third_party_id = @third_party_id;
`
args := pgx.StrictNamedArgs{
"measure_id": measureID,
"third_party_id": thirdPartyID,
}
maps.Copy(args, scope.SQLArguments())
q = fmt.Sprintf(q, scope.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -0,0 +1,22 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
CREATE TABLE measures_third_parties (
measure_id TEXT NOT NULL REFERENCES measures(id) ON DELETE CASCADE,
third_party_id TEXT NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (measure_id, third_party_id)
);

View File

@@ -1448,3 +1448,141 @@ LIMIT 1;
return nil
}
func (v *ThirdParties) CountByMeasureID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
measureID gid.GID,
) (int, error) {
q := `
WITH tps AS (
SELECT
v.id,
v.tenant_id
FROM
third_parties v
INNER JOIN
measures_third_parties mtp ON v.id = mtp.third_party_id
WHERE
mtp.measure_id = @measure_id
)
SELECT
COUNT(id)
FROM
tps
WHERE %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"measure_id": measureID}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count thirdParties: %w", err)
}
return count, nil
}
func (v *ThirdParties) LoadByMeasureID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
measureID gid.GID,
cursor *page.Cursor[ThirdPartyOrderField],
) error {
q := `
WITH tps AS (
SELECT
v.id,
v.tenant_id,
v.organization_id,
v.common_third_party_id,
v.name,
v.description,
v.category,
v.headquarter_address,
v.legal_name,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.certifications,
v.countries,
v.business_owner_profile_id,
v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.created_at,
v.updated_at
FROM
third_parties v
INNER JOIN
measures_third_parties mtp ON v.id = mtp.third_party_id
WHERE
mtp.measure_id = @measure_id
)
SELECT
id,
tenant_id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
created_at,
updated_at
FROM
tps
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"measure_id": measureID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query thirdParties: %w", err)
}
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
if err != nil {
return fmt.Errorf("cannot collect thirdParties: %w", err)
}
*v = thirdParties
return nil
}

View File

@@ -22,23 +22,30 @@ type (
ThirdPartyFilter struct {
showOnTrustCenter *bool
firstLevel *bool
query *string
}
)
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter {
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter {
return &ThirdPartyFilter{
showOnTrustCenter: showOnTrustCenter,
firstLevel: firstLevel,
query: query,
}
}
func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{}
args := pgx.StrictNamedArgs{
"show_on_trust_center": nil,
"filter_query": nil,
}
if f.showOnTrustCenter != nil {
args["show_on_trust_center"] = *f.showOnTrustCenter
} else {
args["show_on_trust_center"] = nil
}
if f.query != nil && *f.query != "" {
args["filter_query"] = *f.query
}
if f.firstLevel != nil {
@@ -58,13 +65,15 @@ func (f *ThirdPartyFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE
END
)
AND
(
CASE
AND CASE
WHEN @first_level::boolean IS NOT NULL THEN
first_level = @first_level::boolean
ELSE TRUE
END
AND CASE
WHEN @filter_query::text IS NOT NULL AND @filter_query::text <> '' THEN
name ILIKE '%' || @filter_query || '%'
ELSE TRUE
END
)`
}

View File

@@ -158,15 +158,17 @@ const (
ActionControlObligationMappingDelete = "core:control:delete-obligation-mapping"
// Measure actions
ActionMeasureGet = "core:measure:get"
ActionMeasureList = "core:measure:list"
ActionMeasureCreate = "core:measure:create"
ActionMeasureUpdate = "core:measure:update"
ActionMeasureDelete = "core:measure:delete"
ActionMeasureEvidenceUpload = "core:measure:upload-evidence"
ActionMeasureImport = "core:measure:import"
ActionMeasureDocumentMappingCreate = "core:measure:create-document-mapping"
ActionMeasureDocumentMappingDelete = "core:measure:delete-document-mapping"
ActionMeasureGet = "core:measure:get"
ActionMeasureList = "core:measure:list"
ActionMeasureCreate = "core:measure:create"
ActionMeasureUpdate = "core:measure:update"
ActionMeasureDelete = "core:measure:delete"
ActionMeasureEvidenceUpload = "core:measure:upload-evidence"
ActionMeasureImport = "core:measure:import"
ActionMeasureDocumentMappingCreate = "core:measure:create-document-mapping"
ActionMeasureDocumentMappingDelete = "core:measure:delete-document-mapping"
ActionMeasureThirdPartyMappingCreate = "core:measure:create-third-party-mapping"
ActionMeasureThirdPartyMappingDelete = "core:measure:delete-third-party-mapping"
// Task actions
ActionTaskGet = "core:task:get"

View File

@@ -644,6 +644,138 @@ func (s MeasureService) CreateDocumentMapping(
return measure, document, nil
}
func (s MeasureService) CountForThirdPartyID(
ctx context.Context, scope coredata.Scoper,
thirdPartyID gid.GID,
filter *coredata.MeasureFilter,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
measures := &coredata.Measures{}
count, err = measures.CountByThirdPartyID(ctx, conn, scope, thirdPartyID, filter)
if err != nil {
return fmt.Errorf("cannot count measures: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s MeasureService) ListForThirdPartyID(
ctx context.Context, scope coredata.Scoper,
thirdPartyID gid.GID,
cursor *page.Cursor[coredata.MeasureOrderField],
filter *coredata.MeasureFilter,
) (*page.Page[*coredata.Measure, coredata.MeasureOrderField], error) {
var measures coredata.Measures
thirdParty := &coredata.ThirdParty{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := thirdParty.LoadByID(ctx, conn, scope, thirdPartyID); err != nil {
return fmt.Errorf("cannot load third party: %w", err)
}
err := measures.LoadByThirdPartyID(ctx, conn, scope, thirdParty.ID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load measures: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(measures, cursor), nil
}
func (s MeasureService) CreateThirdPartyMapping(
ctx context.Context, scope coredata.Scoper,
measureID gid.GID,
thirdPartyID gid.GID,
) (*coredata.Measure, *coredata.ThirdParty, error) {
measure := &coredata.Measure{}
thirdParty := &coredata.ThirdParty{}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := measure.LoadByID(ctx, tx, scope, measureID); err != nil {
return fmt.Errorf("cannot load measure: %w", err)
}
if err := thirdParty.LoadByID(ctx, tx, scope, thirdPartyID); err != nil {
return fmt.Errorf("cannot load third party: %w", err)
}
measureThirdParty := &coredata.MeasureThirdParty{
MeasureID: measure.ID,
ThirdPartyID: thirdParty.ID,
CreatedAt: time.Now(),
}
if err := measureThirdParty.Upsert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot upsert measure third party: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return measure, thirdParty, nil
}
func (s MeasureService) DeleteThirdPartyMapping(
ctx context.Context, scope coredata.Scoper,
measureID gid.GID,
thirdPartyID gid.GID,
) (*coredata.Measure, *coredata.ThirdParty, error) {
measure := &coredata.Measure{}
thirdParty := &coredata.ThirdParty{}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := measure.LoadByID(ctx, tx, scope, measureID); err != nil {
return fmt.Errorf("cannot load measure: %w", err)
}
if err := thirdParty.LoadByID(ctx, tx, scope, thirdPartyID); err != nil {
return fmt.Errorf("cannot load third party: %w", err)
}
measureThirdParty := &coredata.MeasureThirdParty{}
if err := measureThirdParty.Delete(ctx, tx, scope, measure.ID, thirdParty.ID); err != nil {
return fmt.Errorf("cannot delete measure third party mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return measure, thirdParty, nil
}
func (s MeasureService) DeleteDocumentMapping(
ctx context.Context, scope coredata.Scoper,
measureID gid.GID,

View File

@@ -211,14 +211,18 @@ func (cvrar *CreateThirdPartyRiskAssessmentRequest) Validate() error {
func (s ThirdPartyService) CountForOrganizationID(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
filter *coredata.ThirdPartyFilter,
) (int, error) {
var count int
if filter == nil {
filter = coredata.NewThirdPartyFilter(nil, nil, nil)
}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
thirdParties := coredata.ThirdParties{}
filter := &coredata.ThirdPartyFilter{}
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
if err != nil {
@@ -269,6 +273,68 @@ func (s ThirdPartyService) ListForOrganizationID(
return page.NewPage(thirdParties, cursor), nil
}
func (s ThirdPartyService) CountForMeasureID(
ctx context.Context, scope coredata.Scoper,
measureID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
thirdParties := coredata.ThirdParties{}
count, err = thirdParties.CountByMeasureID(ctx, conn, scope, measureID)
if err != nil {
return fmt.Errorf("cannot count thirdParties: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s ThirdPartyService) ListForMeasureID(
ctx context.Context, scope coredata.Scoper,
measureID gid.GID,
cursor *page.Cursor[coredata.ThirdPartyOrderField],
) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) {
var thirdParties coredata.ThirdParties
measure := &coredata.Measure{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := measure.LoadByID(ctx, conn, scope, measureID); err != nil {
return fmt.Errorf("cannot load measure: %w", err)
}
if err := thirdParties.LoadByMeasureID(
ctx,
conn,
scope,
measure.ID,
cursor,
); err != nil {
return fmt.Errorf("cannot load thirdParties: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(thirdParties, cursor), nil
}
func (s ThirdPartyService) CountForDatumID(
ctx context.Context, scope coredata.Scoper,
datumID gid.GID,

View File

@@ -72,7 +72,7 @@ func (r *assetResolver) ThirdParties(ctx context.Context, obj *types.Asset, firs
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// Organization is the resolver for the organization field.
@@ -177,7 +177,7 @@ func (r *datumResolver) ThirdParties(ctx context.Context, obj *types.Datum, firs
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// Organization is the resolver for the organization field.

View File

@@ -95,6 +95,14 @@ type Measure implements Node {
filter: DocumentFilter
): DocumentConnection! @goField(forceResolver: true)
thirdParties(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -126,6 +134,12 @@ extend type Mutation {
deleteMeasureDocumentMapping(
input: DeleteMeasureDocumentMappingInput!
): DeleteMeasureDocumentMappingPayload!
createMeasureThirdPartyMapping(
input: CreateMeasureThirdPartyMappingInput!
): CreateMeasureThirdPartyMappingPayload!
deleteMeasureThirdPartyMapping(
input: DeleteMeasureThirdPartyMappingInput!
): DeleteMeasureThirdPartyMappingPayload!
}
input CreateMeasureInput {
@@ -187,3 +201,23 @@ type DeleteMeasureDocumentMappingPayload {
deletedMeasureId: ID!
deletedDocumentId: ID!
}
input CreateMeasureThirdPartyMappingInput {
measureId: ID!
thirdPartyId: ID!
}
input DeleteMeasureThirdPartyMappingInput {
measureId: ID!
thirdPartyId: ID!
}
type CreateMeasureThirdPartyMappingPayload {
measureEdge: MeasureEdge!
thirdPartyEdge: ThirdPartyEdge!
}
type DeleteMeasureThirdPartyMappingPayload {
deletedMeasureId: ID!
deletedThirdPartyId: ID!
}

View File

@@ -179,6 +179,7 @@ input ThirdPartyOrder
input ThirdPartyFilter {
firstLevel: Boolean
query: String
}
input ThirdPartyComplianceReportOrder
@@ -255,6 +256,15 @@ type ThirdParty implements Node {
orderBy: ThirdPartyRiskAssessmentOrder
): ThirdPartyRiskAssessmentConnection! @goField(forceResolver: true)
measures(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: MeasureOrder
filter: MeasureFilter
): MeasureConnection! @goField(forceResolver: true)
businessOwner: Profile @goField(forceResolver: true)
securityOwner: Profile @goField(forceResolver: true)

View File

@@ -188,6 +188,35 @@ func (r *measureResolver) Documents(ctx context.Context, obj *types.Measure, fir
return types.NewDocumentConnection(pg, r, obj.ID, documentFilter), nil
}
// ThirdParties is the resolver for the thirdParties field.
func (r *measureResolver) ThirdParties(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
Field: coredata.ThirdPartyOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.probo.ThirdParties.ListForMeasureID(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list measure third parties", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// Permission is the resolver for the permission field.
func (r *measureResolver) Permission(ctx context.Context, obj *types.Measure, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -224,6 +253,14 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *thirdPartyResolver:
count, err := r.probo.Measures.CountForThirdPartyID(ctx, scope, obj.ParentID, obj.Filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count measures", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
@@ -388,6 +425,44 @@ func (r *mutationResolver) DeleteMeasureDocumentMapping(ctx context.Context, inp
}, nil
}
// CreateMeasureThirdPartyMapping is the resolver for the createMeasureThirdPartyMapping field.
func (r *mutationResolver) CreateMeasureThirdPartyMapping(ctx context.Context, input types.CreateMeasureThirdPartyMappingInput) (*types.CreateMeasureThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingCreate)
if err != nil {
return nil, err
}
measure, thirdParty, err := r.probo.Measures.CreateThirdPartyMapping(ctx, scope, input.MeasureID, input.ThirdPartyID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create measure third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateMeasureThirdPartyMappingPayload{
MeasureEdge: types.NewMeasureEdge(measure, coredata.MeasureOrderFieldCreatedAt),
ThirdPartyEdge: types.NewThirdPartyEdge(thirdParty, coredata.ThirdPartyOrderFieldCreatedAt),
}, nil
}
// DeleteMeasureThirdPartyMapping is the resolver for the deleteMeasureThirdPartyMapping field.
func (r *mutationResolver) DeleteMeasureThirdPartyMapping(ctx context.Context, input types.DeleteMeasureThirdPartyMappingInput) (*types.DeleteMeasureThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingDelete)
if err != nil {
return nil, err
}
measure, thirdParty, err := r.probo.Measures.DeleteThirdPartyMapping(ctx, scope, input.MeasureID, input.ThirdPartyID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete measure third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteMeasureThirdPartyMappingPayload{
DeletedMeasureID: measure.ID,
DeletedThirdPartyID: thirdParty.ID,
}, nil
}
// Measure returns schema.MeasureResolver implementation.
func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} }

View File

@@ -1291,12 +1291,16 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var firstLevel *bool
var (
firstLevel *bool
query *string
)
if filter != nil {
firstLevel = filter.FirstLevel
query = filter.Query
}
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel, query)
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil {
@@ -1304,7 +1308,7 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
return types.NewThirdPartyConnection(page, r, obj.ID, thirdPartyFilter), nil
}
// ThirdPartiesDocument is the resolver for the thirdPartiesDocument field.

View File

@@ -223,7 +223,7 @@ func (r *processingActivityResolver) ThirdParties(ctx context.Context, obj *type
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// DataProtectionImpactAssessment is the resolver for the dataProtectionImpactAssessment field.

View File

@@ -820,6 +820,40 @@ func (r *thirdPartyResolver) RiskAssessments(ctx context.Context, obj *types.Thi
return types.NewThirdPartyRiskAssessmentConnection(page), nil
}
// Measures is the resolver for the measures field.
func (r *thirdPartyResolver) Measures(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy, filter *types.MeasureFilter) (*types.MeasureConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionMeasureList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.MeasureOrderField]{
Field: coredata.MeasureOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.MeasureOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
if filter != nil {
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State, filter.Category)
}
page, err := r.probo.Measures.ListForThirdPartyID(ctx, scope, obj.ID, cursor, measureFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list third party measures", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil
}
// BusinessOwner is the resolver for the businessOwner field.
func (r *thirdPartyResolver) BusinessOwner(ctx context.Context, obj *types.ThirdParty) (*types.Profile, error) {
if _, err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {
@@ -898,7 +932,7 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil
}
// Permission is the resolver for the permission field.
@@ -1012,7 +1046,7 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.probo.ThirdParties.CountForOrganizationID(ctx, scope, obj.ParentID)
count, err := r.probo.ThirdParties.CountForOrganizationID(ctx, scope, obj.ParentID, obj.Filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count thirdParties", log.Error(err))
return 0, gqlutils.Internal(ctx)
@@ -1043,7 +1077,14 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type
count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *measureResolver:
count, err := r.probo.ThirdParties.CountForMeasureID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count thirdParties", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
@@ -1229,15 +1270,3 @@ type thirdPartyContactResolver struct{ *Resolver }
type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver }
type thirdPartyRiskAssessmentResolver struct{ *Resolver }
type thirdPartyServiceResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
/*
func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) {
panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping"))
}
*/

View File

@@ -31,6 +31,7 @@ type (
Resolver any
ParentID gid.GID
Filters *coredata.ThirdPartyFilter
}
)
@@ -38,6 +39,7 @@ func NewThirdPartyConnection(
p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
parentType any,
parentID gid.GID,
filters *coredata.ThirdPartyFilter,
) *ThirdPartyConnection {
var edges = make([]*ThirdPartyEdge, len(p.Data))
@@ -51,6 +53,7 @@ func NewThirdPartyConnection(
Resolver: parentType,
ParentID: parentID,
Filters: filters,
}
}

View File

@@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel, nil)
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
if err != nil {
@@ -2664,6 +2664,14 @@ func (r *Resolver) LinkMeasureTool(ctx context.Context, req *mcp.CallToolRequest
if _, _, err := svc.Measures.CreateDocumentMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil {
return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to document: %w", err)
}
case coredata.ThirdPartyEntityType:
if _, err := r.Authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingCreate); err != nil {
return nil, types.LinkMeasureOutput{}, err
}
if _, _, err := svc.Measures.CreateThirdPartyMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil {
return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to third party: %w", err)
}
default:
return nil, types.LinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure linking: entity type %d", input.ResourceID.EntityType())
}
@@ -2700,6 +2708,14 @@ func (r *Resolver) UnlinkMeasureTool(ctx context.Context, req *mcp.CallToolReque
if _, _, err := svc.Measures.DeleteDocumentMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil {
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from document: %w", err)
}
case coredata.ThirdPartyEntityType:
if _, err := r.Authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingDelete); err != nil {
return nil, types.UnlinkMeasureOutput{}, err
}
if _, _, err := svc.Measures.DeleteThirdPartyMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil {
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from third party: %w", err)
}
default:
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure unlinking: entity type %d", input.ResourceID.EntityType())
}

View File

@@ -2396,7 +2396,7 @@ components:
description: Measure ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to link (control, risk, or document)
description: ID of the resource to link (control, risk, document, or third party)
LinkMeasureOutput:
type: object
@@ -2412,7 +2412,7 @@ components:
description: Measure ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to unlink (control, risk, or document)
description: ID of the resource to unlink (control, risk, document, or third party)
UnlinkMeasureOutput:
type: object
@@ -12182,7 +12182,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/ListMeasureEvidencesOutput"
- name: linkMeasure
description: Link a measure to a resource (control, risk, or document). The resource type is determined from the resource_id GID.
description: Link a measure to a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:
@@ -12190,7 +12190,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/LinkMeasureOutput"
- name: unlinkMeasure
description: Unlink a measure from a resource (control, risk, or document). The resource type is determined from the resource_id GID.
description: Unlink a measure from a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:

View File

@@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil)
err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil {
@@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId(
thirdParties := &coredata.ThirdParties{}
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil)
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter)
if err != nil {