Add vendor publish to document system

Replace the old snapshot-based system for vendors with the publish
document system, mirroring the prior processing activity / DPIA / TIA
migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n
operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering vendor profile fields plus per-vendor
sections for services, contacts, risk assessments, compliance reports,
BAA and DPA agreements.

The vendor register lives as a generated DocumentTypeRegister document
on the organization, reused across publishes (the major version bumps
on every republish). Approvers can be passed in to create a draft
pending approval; otherwise the version is published immediately. The
frontend Vendors page exposes a Publish button and a Document link
button when the document exists, and pre-fills the previous default
approvers.

Remove snapshot mode entirely from vendors and their sub-entities: drop
snapshotId/sourceId from GraphQL Vendor type and VendorFilter; remove
SnapshotsTypeVendors from the snapshot registry and delete
Vendors.Snapshot, VendorSnapshotter interface and all
*.InsertVendorSnapshots methods on contacts, services, risk
assessments, compliance reports, BAA and DPA. Drop the snapshot routes
and banner from the frontend. The snapshot_id columns remain in the
database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Vendor backed by a
new vendors_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Obligation pattern.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-28 18:54:09 +02:00
parent 5629c8ccc0
commit c026f67bd9
40 changed files with 3144 additions and 792 deletions

View File

@@ -0,0 +1,148 @@
// 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 publish
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const publishMutation = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishVendorList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
Major int `json:"major"`
Minor int `json:"minor"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the vendor register as a document version",
Example: ` # Publish the vendor register
prb vendor publish --org ORG_ID
# Publish with approvers
prb vendor publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
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
}
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
input := map[string]any{
"organizationId": flagOrg,
}
if len(flagApprover) > 0 {
input["approverIds"] = flagApprover
}
data, err := client.Do(
publishMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp publishResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.PublishVendorList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published vendor register %s (v%d.%d)\n",
v.Title,
v.Major,
v.Minor,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
return cmd
}

View File

@@ -21,6 +21,7 @@ import (
"go.probo.inc/probo/pkg/cmd/vendormgmt/create"
"go.probo.inc/probo/pkg/cmd/vendormgmt/delete"
"go.probo.inc/probo/pkg/cmd/vendormgmt/list"
"go.probo.inc/probo/pkg/cmd/vendormgmt/publish"
"go.probo.inc/probo/pkg/cmd/vendormgmt/update"
"go.probo.inc/probo/pkg/cmd/vendormgmt/view"
)
@@ -37,6 +38,7 @@ func NewCmdVendor(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}

View File

@@ -0,0 +1,16 @@
-- 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.
ALTER TABLE generated_documents
ADD COLUMN vendors_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;

View File

@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND type = 'RISKS'
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND type = 'RISKS'
AND %s
`

View File

@@ -25,7 +25,6 @@ type (
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
SnapshotsTypeVendors SnapshotsType = "VENDORS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
@@ -37,7 +36,6 @@ const (
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
}
}
@@ -59,8 +57,6 @@ func (st *SnapshotsType) Scan(value any) error {
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
case SnapshotsTypeVendors.String():
*st = SnapshotsTypeVendors
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():

View File

@@ -30,8 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeVendors:
return Vendors{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
func (v Vendor) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
vendors_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
}
return documentID, nil
}
func (v Vendor) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
tenant_id,
vendors_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@vendors_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
vendors_document_id = @vendors_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"vendors_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert vendor list document ID: %w", err)
}
return nil
}
func (v Vendor) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
vendors_document_id = NULL,
updated_at = @now
WHERE
vendors_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear vendor list document references: %w", err)
}
return nil
}
type (
Vendor struct {
ID gid.GID `db:"id"`
@@ -52,17 +159,11 @@ type (
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Vendors []*Vendor
VendorSnapshotter interface {
InsertVendorSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
@@ -123,8 +224,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -191,8 +290,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -253,8 +350,6 @@ INSERT INTO
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -282,8 +377,6 @@ VALUES (
@security_page_url,
@trust_page_url,
@show_on_trust_center,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -313,8 +406,6 @@ VALUES (
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
"snapshot_id": v.SnapshotID,
"source_id": v.SourceID,
"created_at": v.CreatedAt,
"updated_at": v.UpdatedAt,
}
@@ -355,7 +446,8 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -375,6 +467,67 @@ WHERE
return count, nil
}
func (v *Vendors) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
tenant_id,
organization_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,
created_at,
updated_at
FROM
vendors
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendors: %w", err)
}
vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
if err != nil {
return fmt.Errorf("cannot collect vendors: %w", err)
}
*v = vendors
return nil
}
func (v *Vendors) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
@@ -408,8 +561,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -417,6 +568,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -613,8 +765,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -648,8 +798,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -749,8 +897,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -784,8 +930,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -846,8 +990,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -881,8 +1023,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -944,8 +1084,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -979,8 +1117,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -1034,6 +1170,7 @@ filtered_vendors AS (
vendors v
WHERE
v.tenant_id = @tenant_id
AND v.snapshot_id IS NULL
)
SELECT
pav.processing_activity_id,
@@ -1106,8 +1243,6 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
@@ -1141,8 +1276,6 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -1169,108 +1302,3 @@ ORDER BY name ASC
return nil
}
func (v Vendors) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
for _, snapshotter := range []VendorSnapshotter{
Vendors{},
VendorServices{},
VendorContacts{},
VendorRiskAssessments{},
VendorComplianceReports{},
VendorBusinessAssociateAgreements{},
VendorDataPrivacyAgreements{},
} {
if err := snapshotter.InsertVendorSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create vendor snapshots: (%T) %w", snapshotter, err)
}
}
return nil
}
func (v Vendors) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO vendors (
tenant_id,
id,
snapshot_id,
source_id,
organization_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,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
@snapshot_id,
v.id,
v.organization_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.created_at,
v.updated_at
FROM vendors v
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_entity_type": VendorEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
func (vbaas *VendorBusinessAssociateAgreements) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vbaas = VendorBusinessAssociateAgreements{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
FROM
vendor_business_associate_agreements
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor business associate agreements: %w", err)
}
agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorBusinessAssociateAgreement])
if err != nil {
return fmt.Errorf("cannot collect vendor business associate agreements: %w", err)
}
*vbaas = agreements
return nil
}
func (vbaa *VendorBusinessAssociateAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vbaa.ValidFrom,
"valid_until": vbaa.ValidUntil,
"file_id": vbaa.FileID,
"snapshot_id": vbaa.SnapshotID,
"source_id": vbaa.SourceID,
"created_at": vbaa.CreatedAt,
"updated_at": vbaa.UpdatedAt,
}
@@ -317,65 +358,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (v VendorBusinessAssociateAgreements) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_business_associate_agreements (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_business_associate_agreement_entity_type),
@snapshot_id,
vbaa.id,
vbaa.organization_id,
sv.id,
vbaa.valid_from,
vbaa.valid_until,
vbaa.file_id,
vbaa.created_at,
vbaa.updated_at
FROM vendor_business_associate_agreements vbaa
INNER JOIN snapshot_vendors sv ON sv.source_id = vbaa.vendor_id
WHERE %s AND vbaa.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_business_associate_agreement_entity_type": VendorBusinessAssociateAgreementEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor business associate agreement snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidUntil *time.Time `db:"valid_until"`
ReportName string `db:"report_name"`
ReportFileId *gid.GID `db:"report_file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -86,8 +84,6 @@ SELECT
valid_until,
report_name,
report_file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -95,6 +91,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
@@ -119,6 +116,63 @@ WHERE
return nil
}
func (vcs *VendorComplianceReports) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vcs = VendorComplianceReports{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
report_date,
valid_until,
report_name,
report_file_id,
created_at,
updated_at
FROM
vendor_compliance_reports
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, report_date DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor compliance reports: %w", err)
}
vendorComplianceReports, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorComplianceReport])
if err != nil {
return fmt.Errorf("cannot collect vendor compliance reports: %w", err)
}
*vcs = vendorComplianceReports
return nil
}
func (vcr *VendorComplianceReport) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -134,8 +188,6 @@ SELECT
valid_until,
report_name,
report_file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -251,67 +303,3 @@ RETURNING report_file_id
}
return nil
}
func (vcrs VendorComplianceReports) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_compliance_reports (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
report_date,
valid_until,
report_name,
report_file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_compliance_report_entity_type),
@organization_id,
@snapshot_id,
vcr.id,
sv.id,
vcr.report_date,
vcr.valid_until,
vcr.report_name,
vcr.report_file_id,
vcr.created_at,
vcr.updated_at
FROM vendor_compliance_reports vcr
INNER JOIN snapshot_vendors sv ON sv.source_id = vcr.vendor_id
WHERE %s AND vcr.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_compliance_report_entity_type": VendorComplianceReportEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor compliance report snapshots: %w", err)
}
return nil
}

View File

@@ -37,8 +37,6 @@ type (
Email *mail.Addr `db:"email"`
Phone *string `db:"phone"`
Role *string `db:"role"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -88,8 +86,6 @@ SELECT
email,
phone,
role,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -141,8 +137,6 @@ SELECT
email,
phone,
role,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -150,6 +144,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -176,6 +171,63 @@ WHERE
return nil
}
func (vc *VendorContacts) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vc = VendorContacts{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
full_name,
email,
phone,
role,
created_at,
updated_at
FROM
vendor_contacts
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, full_name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor contacts: %w", err)
}
defer rows.Close()
vendorContacts, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorContact])
if err != nil {
return fmt.Errorf("cannot collect vendor contacts: %w", err)
}
*vc = vendorContacts
return nil
}
func (vc VendorContact) Insert(
ctx context.Context,
conn pg.Tx,
@@ -296,67 +348,3 @@ WHERE
return nil
}
func (vc VendorContacts) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_contacts (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
full_name,
email,
phone,
role,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_contact_entity_type),
@organization_id,
@snapshot_id,
vc.id,
sv.id,
vc.full_name,
vc.email,
vc.phone,
vc.role,
vc.created_at,
vc.updated_at
FROM vendor_contacts vc
INNER JOIN snapshot_vendors sv ON sv.source_id = vc.vendor_id
WHERE %s AND vc.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_contact_entity_type": VendorContactEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor contact snapshots: %w", err)
}
return nil
}

View File

@@ -36,8 +36,6 @@ type (
ValidFrom *time.Time `db:"valid_from"`
ValidUntil *time.Time `db:"valid_until"`
FileID gid.GID `db:"file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -84,8 +82,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -93,6 +89,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -116,6 +113,60 @@ LIMIT 1;
return nil
}
func (vdpas *VendorDataPrivacyAgreements) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vdpas = VendorDataPrivacyAgreements{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
FROM
vendor_data_privacy_agreements
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.NamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor data privacy agreements: %w", err)
}
agreements, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorDataPrivacyAgreement])
if err != nil {
return fmt.Errorf("cannot collect vendor data privacy agreements: %w", err)
}
*vdpas = agreements
return nil
}
func (vdpa *VendorDataPrivacyAgreement) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -130,8 +181,6 @@ SELECT
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -215,8 +264,6 @@ INSERT INTO
valid_from,
valid_until,
file_id,
snapshot_id,
source_id,
created_at,
updated_at
)
@@ -228,8 +275,6 @@ VALUES (
@valid_from,
@valid_until,
@file_id,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -238,8 +283,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
valid_from = EXCLUDED.valid_from,
valid_until = EXCLUDED.valid_until,
file_id = EXCLUDED.file_id,
snapshot_id = EXCLUDED.snapshot_id,
source_id = EXCLUDED.source_id,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
@@ -250,8 +293,6 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
"valid_from": vdpa.ValidFrom,
"valid_until": vdpa.ValidUntil,
"file_id": vdpa.FileID,
"snapshot_id": vdpa.SnapshotID,
"source_id": vdpa.SourceID,
"created_at": vdpa.CreatedAt,
"updated_at": vdpa.UpdatedAt,
}
@@ -316,65 +357,3 @@ WHERE
_, err := conn.Exec(ctx, q, args)
return err
}
func (vdpa VendorDataPrivacyAgreements) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_data_privacy_agreements (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
valid_from,
valid_until,
file_id,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_data_privacy_agreement_entity_type),
@snapshot_id,
vdpa.id,
vdpa.organization_id,
sv.id,
vdpa.valid_from,
vdpa.valid_until,
vdpa.file_id,
vdpa.created_at,
vdpa.updated_at
FROM vendor_data_privacy_agreements vdpa
INNER JOIN snapshot_vendors sv ON sv.source_id = vdpa.vendor_id
WHERE %s AND vdpa.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_data_privacy_agreement_entity_type": VendorDataPrivacyAgreementEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor data privacy agreement snapshots: %w", err)
}
return nil
}

View File

@@ -16,19 +16,16 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
VendorFilter struct {
showOnTrustCenter *bool
snapshotID **gid.GID
}
)
func NewVendorFilter(snapshotID **gid.GID, showOnTrustCenter *bool) *VendorFilter {
func NewVendorFilter(showOnTrustCenter *bool) *VendorFilter {
return &VendorFilter{
snapshotID: snapshotID,
showOnTrustCenter: showOnTrustCenter,
}
}
@@ -42,17 +39,6 @@ func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil
}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
@@ -64,14 +50,5 @@ func (f *VendorFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE
END
AND
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END
)`
}

View File

@@ -37,8 +37,6 @@ type (
DataSensitivity DataSensitivity `db:"data_sensitivity"`
BusinessImpact BusinessImpact `db:"business_impact"`
Notes *string `db:"notes"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -137,8 +135,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -186,8 +182,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -195,6 +189,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
ORDER BY
created_at DESC
LIMIT 1;
@@ -238,8 +233,6 @@ SELECT
data_sensitivity,
business_impact,
notes,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -247,6 +240,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
@@ -271,66 +265,59 @@ WHERE
return nil
}
func (v VendorRiskAssessments) InsertVendorSnapshots(
func (r *VendorRiskAssessments) LoadByVendorIDs(
ctx context.Context,
conn pg.Tx,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
vendorIDs []gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_risk_assessments (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
vendor_id,
expires_at,
data_sensitivity,
business_impact,
notes,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_risk_assessment_entity_type),
@snapshot_id,
vra.id,
vra.organization_id,
sv.id,
vra.expires_at,
vra.data_sensitivity,
vra.business_impact,
vra.notes,
vra.created_at,
vra.updated_at
FROM vendor_risk_assessments vra
INNER JOIN snapshot_vendors sv ON sv.source_id = vra.vendor_id
WHERE %s AND vra.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_risk_assessment_entity_type": VendorRiskAssessmentEntityType,
if len(vendorIDs) == 0 {
*r = VendorRiskAssessments{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
expires_at,
data_sensitivity,
business_impact,
notes,
created_at,
updated_at
FROM
vendor_risk_assessments
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, created_at DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert vendor risk assessment snapshots: %w", err)
return fmt.Errorf("cannot query risk assessments: %w", err)
}
assessments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorRiskAssessment])
if err != nil {
return fmt.Errorf("cannot collect risk assessments: %w", err)
}
*r = assessments
return nil
}

View File

@@ -34,8 +34,6 @@ type (
VendorID gid.GID `db:"vendor_id"`
Name string `db:"name"`
Description *string `db:"description"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -81,8 +79,6 @@ SELECT
vendor_id,
name,
description,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -132,8 +128,6 @@ SELECT
vendor_id,
name,
description,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -141,6 +135,7 @@ FROM
WHERE
%s
AND vendor_id = @vendor_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -167,6 +162,61 @@ WHERE
return nil
}
func (vs *VendorServices) LoadByVendorIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
vendorIDs []gid.GID,
) error {
if len(vendorIDs) == 0 {
*vs = VendorServices{}
return nil
}
q := `
SELECT
id,
organization_id,
vendor_id,
name,
description,
created_at,
updated_at
FROM
vendor_services
WHERE
%s
AND vendor_id = ANY(@vendor_ids)
AND snapshot_id IS NULL
ORDER BY
vendor_id, name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
ids := make([]string, len(vendorIDs))
for i, id := range vendorIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{"vendor_ids": ids}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendor services: %w", err)
}
defer rows.Close()
vendorServices, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[VendorService])
if err != nil {
return fmt.Errorf("cannot collect vendor services: %w", err)
}
*vs = vendorServices
return nil
}
func (vs VendorService) Insert(
ctx context.Context,
conn pg.Tx,
@@ -277,63 +327,3 @@ WHERE
return nil
}
func (vs VendorServices) InsertVendorSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
)
INSERT INTO vendor_services (
tenant_id,
id,
organization_id,
snapshot_id,
source_id,
vendor_id,
name,
description,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_service_entity_type),
@organization_id,
@snapshot_id,
vs.id,
sv.id,
vs.name,
vs.description,
vs.created_at,
vs.updated_at
FROM vendor_services vs
INNER JOIN snapshot_vendors sv ON sv.source_id = vs.vendor_id
WHERE %s AND vs.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_service_entity_type": VendorServiceEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor service snapshots: %w", err)
}
return nil
}

View File

@@ -366,6 +366,73 @@ type (
LocalLawRisk string
SupplementaryMeasures string
}
VendorListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalVendors int
Rows []VendorListRow
}
VendorListRow struct {
Name string
LegalName string
Description string
Category string
HeadquarterAddress string
WebsiteURL string
PrivacyPolicyURL string
ServiceLevelAgreementURL string
DataProcessingAgreementURL string
BusinessAssociateAgreementURL string
SubprocessorsListURL string
StatusPageURL string
TermsOfServiceURL string
SecurityPageURL string
TrustPageURL string
Certifications string
Countries string
BusinessOwner string
SecurityOwner string
Services []VendorListService
Contacts []VendorListContact
RiskAssessments []VendorListRiskAssessment
ComplianceReports []VendorListComplianceReport
BusinessAssociateAgreement *VendorListAgreement
DataPrivacyAgreement *VendorListAgreement
}
VendorListService struct {
Name string
Description string
}
VendorListContact struct {
FullName string
Email string
Phone string
Role string
}
VendorListRiskAssessment struct {
AssessedAt string
ExpiresAt string
DataSensitivity string
BusinessImpact string
Notes string
}
VendorListComplianceReport struct {
ReportName string
ReportDate string
ValidUntil string
}
VendorListAgreement struct {
ValidFrom string
ValidUntil string
}
)
func BoolLabel(v bool) string {

View File

@@ -84,12 +84,13 @@ const (
ActionTrustCenterFileCreate = "core:trust-center-file:create"
// Vendor actions
ActionVendorList = "core:vendor:list"
ActionVendorGet = "core:vendor:get"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
ActionVendorAssess = "core:vendor:assess"
ActionVendorList = "core:vendor:list"
ActionVendorGet = "core:vendor:get"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
ActionVendorAssess = "core:vendor:assess"
ActionVendorPublish = "core:vendor:publish"
// VendorContact actions
ActionVendorContactGet = "core:vendor-contact:get"

View File

@@ -2563,3 +2563,536 @@ func BuildTransferImpactAssessmentListDocument(data docgen.TransferImpactAssessm
}
return buf.String(), nil
}
func (s *GeneratedDocumentService) PublishVendorList(
ctx context.Context,
organizationID gid.GID,
approverIDs []gid.GID,
) (*coredata.Document, *coredata.DocumentVersion, error) {
// Phase 1: collect data and render the prosemirror document outside any
// write transaction. Both the bulk reads of vendors + sub-entities and the
// JSON template rendering are slow enough that holding write locks across
// them would needlessly block other writers.
var documentData docgen.VendorListData
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
var err error
documentData, err = s.buildVendorListDocumentData(ctx, conn, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
return nil
})
if err != nil {
return nil, nil, err
}
prosemirrorJSON, err := BuildVendorListDocument(documentData)
if err != nil {
return nil, nil, fmt.Errorf("cannot build prosemirror document: %w", err)
}
// Phase 2: persist the document and version in a write transaction.
var (
document *coredata.Document
documentVersion *coredata.DocumentVersion
)
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
now := time.Now()
vendor := coredata.Vendor{}
vendorDocumentID, err := vendor.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if vendorDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *vendorDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load vendor list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
if err := vendor.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*vendorDocumentID}); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: organizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
if err := vendor.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
}
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion = &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Vendors",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeRegister,
Orientation: coredata.DocumentVersionOrientationPortrait,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
}
return fmt.Errorf("cannot insert document version: %w", err)
}
if hasApprovers {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
return fmt.Errorf("cannot save default approvers: %w", err)
}
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
ctx,
tx,
document,
documentVersion,
approverIDs,
nil,
)
if err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
} else {
zero := 0
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = &zero
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) GetVendorsDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
vendor := coredata.Vendor{}
var err error
documentID, err = vendor.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if err != nil {
return nil, fmt.Errorf("cannot get vendor list document ID: %w", err)
}
return documentID, nil
}
func (s *GeneratedDocumentService) buildVendorListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.VendorListData, error) {
var vendors coredata.Vendors
if err := vendors.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendors: %w", err)
}
if len(vendors) == 0 {
return docgen.VendorListData{
Title: "Vendors",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalVendors: 0,
}, nil
}
ownerIDSet := make(map[gid.GID]struct{})
ownerIDs := make([]gid.GID, 0)
for _, v := range vendors {
if v.BusinessOwnerID != nil {
if _, ok := ownerIDSet[*v.BusinessOwnerID]; !ok {
ownerIDs = append(ownerIDs, *v.BusinessOwnerID)
ownerIDSet[*v.BusinessOwnerID] = struct{}{}
}
}
if v.SecurityOwnerID != nil {
if _, ok := ownerIDSet[*v.SecurityOwnerID]; !ok {
ownerIDs = append(ownerIDs, *v.SecurityOwnerID)
ownerIDSet[*v.SecurityOwnerID] = struct{}{}
}
}
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile)
if len(ownerIDs) > 0 {
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load owner profiles: %w", err)
}
for _, p := range profiles {
profileMap[p.ID] = p
}
}
vendorIDs := make([]gid.GID, len(vendors))
for i, v := range vendors {
vendorIDs[i] = v.ID
}
var allServices coredata.VendorServices
if err := allServices.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor services: %w", err)
}
servicesByVendor := make(map[gid.GID]coredata.VendorServices, len(vendors))
for _, vs := range allServices {
servicesByVendor[vs.VendorID] = append(servicesByVendor[vs.VendorID], vs)
}
var allContacts coredata.VendorContacts
if err := allContacts.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor contacts: %w", err)
}
contactsByVendor := make(map[gid.GID]coredata.VendorContacts, len(vendors))
for _, c := range allContacts {
contactsByVendor[c.VendorID] = append(contactsByVendor[c.VendorID], c)
}
var allAssessments coredata.VendorRiskAssessments
if err := allAssessments.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor risk assessments: %w", err)
}
assessmentsByVendor := make(map[gid.GID]coredata.VendorRiskAssessments, len(vendors))
for _, ra := range allAssessments {
assessmentsByVendor[ra.VendorID] = append(assessmentsByVendor[ra.VendorID], ra)
}
var allReports coredata.VendorComplianceReports
if err := allReports.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor compliance reports: %w", err)
}
reportsByVendor := make(map[gid.GID]coredata.VendorComplianceReports, len(vendors))
for _, r := range allReports {
reportsByVendor[r.VendorID] = append(reportsByVendor[r.VendorID], r)
}
var allBAAs coredata.VendorBusinessAssociateAgreements
if err := allBAAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor business associate agreements: %w", err)
}
baaByVendor := make(map[gid.GID]*coredata.VendorBusinessAssociateAgreement, len(allBAAs))
for _, b := range allBAAs {
baaByVendor[b.VendorID] = b
}
var allDPAs coredata.VendorDataPrivacyAgreements
if err := allDPAs.LoadByVendorIDs(ctx, conn, s.svc.scope, vendorIDs); err != nil {
return docgen.VendorListData{}, fmt.Errorf("cannot load vendor data privacy agreements: %w", err)
}
dpaByVendor := make(map[gid.GID]*coredata.VendorDataPrivacyAgreement, len(allDPAs))
for _, d := range allDPAs {
dpaByVendor[d.VendorID] = d
}
rows := make([]docgen.VendorListRow, 0, len(vendors))
for _, v := range vendors {
row := docgen.VendorListRow{
Name: v.Name,
LegalName: derefStringOrNotSpecified(v.LegalName),
Description: derefStringOrNotSpecified(v.Description),
Category: formatVendorCategory(v.Category),
HeadquarterAddress: derefStringOrNotSpecified(v.HeadquarterAddress),
WebsiteURL: derefStringOrNotSpecified(v.WebsiteURL),
PrivacyPolicyURL: derefStringOrNotSpecified(v.PrivacyPolicyURL),
ServiceLevelAgreementURL: derefStringOrNotSpecified(v.ServiceLevelAgreementURL),
DataProcessingAgreementURL: derefStringOrNotSpecified(v.DataProcessingAgreementURL),
BusinessAssociateAgreementURL: derefStringOrNotSpecified(v.BusinessAssociateAgreementURL),
SubprocessorsListURL: derefStringOrNotSpecified(v.SubprocessorsListURL),
StatusPageURL: derefStringOrNotSpecified(v.StatusPageURL),
TermsOfServiceURL: derefStringOrNotSpecified(v.TermsOfServiceURL),
SecurityPageURL: derefStringOrNotSpecified(v.SecurityPageURL),
TrustPageURL: derefStringOrNotSpecified(v.TrustPageURL),
Certifications: joinOrNotSpecified(v.Certifications),
Countries: formatCountries(v.Countries),
BusinessOwner: lookupProfileName(profileMap, v.BusinessOwnerID),
SecurityOwner: lookupProfileName(profileMap, v.SecurityOwnerID),
}
for _, vs := range servicesByVendor[v.ID] {
row.Services = append(row.Services, docgen.VendorListService{
Name: vs.Name,
Description: derefStringOrNotSpecified(vs.Description),
})
}
for _, c := range contactsByVendor[v.ID] {
email := ""
if c.Email != nil {
email = c.Email.String()
}
row.Contacts = append(row.Contacts, docgen.VendorListContact{
FullName: derefStringOrNotSpecified(c.FullName),
Email: stringOrNotSpecified(email),
Phone: derefStringOrNotSpecified(c.Phone),
Role: derefStringOrNotSpecified(c.Role),
})
}
for _, ra := range assessmentsByVendor[v.ID] {
row.RiskAssessments = append(row.RiskAssessments, docgen.VendorListRiskAssessment{
AssessedAt: ra.CreatedAt.Format("2006-01-02"),
ExpiresAt: ra.ExpiresAt.Format("2006-01-02"),
DataSensitivity: formatDataSensitivity(ra.DataSensitivity),
BusinessImpact: formatBusinessImpact(ra.BusinessImpact),
Notes: derefStringOrNotSpecified(ra.Notes),
})
}
for _, r := range reportsByVendor[v.ID] {
row.ComplianceReports = append(row.ComplianceReports, docgen.VendorListComplianceReport{
ReportName: r.ReportName,
ReportDate: r.ReportDate.Format("2006-01-02"),
ValidUntil: formatTimeOrNotSpecified(r.ValidUntil),
})
}
if baa := baaByVendor[v.ID]; baa != nil {
row.BusinessAssociateAgreement = &docgen.VendorListAgreement{
ValidFrom: formatTimeOrNotSpecified(baa.ValidFrom),
ValidUntil: formatTimeOrNotSpecified(baa.ValidUntil),
}
}
if dpa := dpaByVendor[v.ID]; dpa != nil {
row.DataPrivacyAgreement = &docgen.VendorListAgreement{
ValidFrom: formatTimeOrNotSpecified(dpa.ValidFrom),
ValidUntil: formatTimeOrNotSpecified(dpa.ValidUntil),
}
}
rows = append(rows, row)
}
return docgen.VendorListData{
Title: "Vendors",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalVendors: len(vendors),
Rows: rows,
}, nil
}
func stringOrNotSpecified(s string) string {
if s == "" {
return "Not specified"
}
return s
}
func formatTimeOrNotSpecified(t *time.Time) string {
if t == nil {
return "Not specified"
}
return t.Format("2006-01-02")
}
func joinOrNotSpecified(items []string) string {
if len(items) == 0 {
return "Not specified"
}
return strings.Join(items, ", ")
}
func formatCountries(c coredata.CountryCodes) string {
if len(c) == 0 {
return "Not specified"
}
parts := make([]string, len(c))
for i, cc := range c {
parts[i] = string(cc)
}
return strings.Join(parts, ", ")
}
func lookupProfileName(profiles map[gid.GID]*coredata.MembershipProfile, id *gid.GID) string {
if id == nil {
return "Not assigned"
}
if p, ok := profiles[*id]; ok {
return p.FullName
}
return "Not assigned"
}
func formatDataSensitivity(s coredata.DataSensitivity) string {
switch s {
case coredata.DataSensitivityNone:
return "None"
case coredata.DataSensitivityLow:
return "Low"
case coredata.DataSensitivityMedium:
return "Medium"
case coredata.DataSensitivityHigh:
return "High"
case coredata.DataSensitivityCritical:
return "Critical"
default:
return string(s)
}
}
func formatBusinessImpact(b coredata.BusinessImpact) string {
switch b {
case coredata.BusinessImpactLow:
return "Low"
case coredata.BusinessImpactMedium:
return "Medium"
case coredata.BusinessImpactHigh:
return "High"
case coredata.BusinessImpactCritical:
return "Critical"
default:
return string(b)
}
}
func formatVendorCategory(c coredata.VendorCategory) string {
switch c {
case coredata.VendorCategoryAnalytics:
return "Analytics"
case coredata.VendorCategoryCloudMonitoring:
return "Cloud Monitoring"
case coredata.VendorCategoryCloudProvider:
return "Cloud Provider"
case coredata.VendorCategoryCollaboration:
return "Collaboration"
case coredata.VendorCategoryCustomerSupport:
return "Customer Support"
case coredata.VendorCategoryDataStorageAndProcessing:
return "Data Storage and Processing"
case coredata.VendorCategoryDocumentManagement:
return "Document Management"
case coredata.VendorCategoryEmployeeManagement:
return "Employee Management"
case coredata.VendorCategoryEngineering:
return "Engineering"
case coredata.VendorCategoryFinance:
return "Finance"
case coredata.VendorCategoryIdentityProvider:
return "Identity Provider"
case coredata.VendorCategoryIT:
return "IT"
case coredata.VendorCategoryMarketing:
return "Marketing"
case coredata.VendorCategoryOfficeOperations:
return "Office Operations"
case coredata.VendorCategoryOther:
return "Other"
case coredata.VendorCategoryPasswordManagement:
return "Password Management"
case coredata.VendorCategoryProductAndDesign:
return "Product and Design"
case coredata.VendorCategoryProfessionalServices:
return "Professional Services"
case coredata.VendorCategoryRecruiting:
return "Recruiting"
case coredata.VendorCategorySales:
return "Sales"
case coredata.VendorCategorySecurity:
return "Security"
case coredata.VendorCategoryVersionControl:
return "Version Control"
default:
return string(c)
}
}
var vendorListTemplate = template.Must(
template.New("vendor_list.json.tmpl").
Funcs(template.FuncMap{
"json": func(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
},
"printf": fmt.Sprintf,
"add": func(a, b int) int { return a + b },
}).
ParseFS(Templates, "templates/vendor_list.json.tmpl"),
)
func BuildVendorListDocument(data docgen.VendorListData) (string, error) {
var buf bytes.Buffer
if err := vendorListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute vendor list template: %w", err)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,302 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "1. Purpose" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "This document provides a comprehensive register of all vendors used by the organization. It captures vendor profile information, services consumed, contacts, risk assessments, compliance reports, and contractual agreements (BAA, DPA) for each vendor." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Vendors" }]
}{{range $i, $r := .Rows}},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.Name)}} }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.1 General Information" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Legal Name: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.LegalName}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Description: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Description}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Category: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Category}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Headquarter Address: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.HeadquarterAddress}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Countries: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Countries}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Certifications: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Certifications}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.2 URLs & Pages" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Website: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.WebsiteURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Privacy Policy: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.PrivacyPolicyURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Service Level Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.ServiceLevelAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Data Processing Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.DataProcessingAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Associate Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.BusinessAssociateAgreementURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Subprocessors List: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SubprocessorsListURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Status Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.StatusPageURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Terms of Service: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.TermsOfServiceURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Security Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SecurityPageURL}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Trust Page: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.TrustPageURL}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.3 Owners" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.BusinessOwner}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Security Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.SecurityOwner}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.4 Services" (add $i 1))}} }]
}{{if $r.Services}}{{range $r.Services}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .Name)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .Description}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No services recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.5 Contacts" (add $i 1))}} }]
}{{if $r.Contacts}}{{range $r.Contacts}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .FullName)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json (printf "%s, %s, %s" .Role .Email .Phone)}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No contacts recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.6 Risk Assessments" (add $i 1))}} }]
}{{if $r.RiskAssessments}}{{range $r.RiskAssessments}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Assessed on: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .AssessedAt}} },
{ "type": "text", "text": " · Expires on: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .ExpiresAt}} },
{ "type": "text", "text": " · Data Sensitivity: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .DataSensitivity}} },
{ "type": "text", "text": " · Business Impact: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .BusinessImpact}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Notes: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json .Notes}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No risk assessments recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.7 Compliance Reports" (add $i 1))}} }]
}{{if $r.ComplianceReports}}{{range $r.ComplianceReports}},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": {{json (printf "%s — " .ReportName)}}, "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json (printf "Date: %s · Valid Until: %s" .ReportDate .ValidUntil)}} }
]
}{{end}}{{else}},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "No compliance reports recorded." }]
}{{end}},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.8 Agreements" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Business Associate Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{if $r.BusinessAssociateAgreement}}{{json (printf "Yes — From %s until %s" $r.BusinessAssociateAgreement.ValidFrom $r.BusinessAssociateAgreement.ValidUntil)}}{{else}}"No"{{end}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Data Privacy Agreement: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{if $r.DataPrivacyAgreement}}{{json (printf "Yes — From %s until %s" $r.DataPrivacyAgreement.ValidFrom $r.DataPrivacyAgreement.ValidUntil)}}{{else}}"No"{{end}} }
]
}{{end}},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Annexes" }]
},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "3.1 Lexicon" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Data Sensitivity" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "None: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "No sensitive data is shared with the vendor." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes low sensitivity data such as public information." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes medium sensitivity data such as internal business data." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes high sensitivity data such as personal data, financial data, or trade secrets." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Critical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The vendor processes the most sensitive categories of data, where unauthorized disclosure would cause severe harm." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Business Impact" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Limited disruption to operations if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Significant disruption to operations if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Severe disruption or outage if the vendor service is unavailable." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Critical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Operations cannot continue if the vendor service is unavailable; immediate business-wide impact." }] }] }
]
}
]
}

View File

@@ -329,9 +329,10 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: VendorOrder
filter: VendorFilter = { snapshotId: null }
): VendorConnection! @goField(forceResolver: true)
vendorsDocument: Document @goField(forceResolver: true)
webhookSubscriptions(
first: Int
after: CursorKey

View File

@@ -1,16 +1,6 @@
enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
PROCESSING_ACTIVITIES
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
)
STATEMENTS_OF_APPLICABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeStatementsOfApplicability"
)
}
enum SnapshotOrderField

View File

@@ -206,13 +206,8 @@ input VendorRiskAssessmentOrder {
direction: OrderDirection!
}
input VendorFilter {
snapshotId: ID
}
type Vendor implements Node {
id: ID!
snapshotId: ID
name: String!
category: VendorCategory!
description: String
@@ -462,6 +457,19 @@ extend type Mutation {
input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload!
assessVendor(input: AssessVendorInput!): AssessVendorPayload!
publishVendorList(
input: PublishVendorListInput!
): PublishVendorListPayload!
}
input PublishVendorListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishVendorListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateVendorInput {

View File

@@ -1210,7 +1210,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
}
// Vendors is the resolver for the vendors field.
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy, filter *types.VendorFilter) (*types.VendorConnection, error) {
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy) (*types.VendorConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
return nil, err
}
@@ -1230,10 +1230,7 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var vendorFilter = coredata.NewVendorFilter(nil, nil)
if filter != nil {
vendorFilter = coredata.NewVendorFilter(&filter.SnapshotID, nil)
}
vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, obj.ID, cursor, vendorFilter)
if err != nil {
@@ -1244,6 +1241,35 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
return types.NewVendorConnection(page, r, obj.ID), nil
}
// VendorsDocument is the resolver for the vendorsDocument field.
func (r *organizationResolver) VendorsDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
documentID, err := prb.GeneratedDocuments.GetVendorsDocumentID(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get vendors document ID", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if documentID == nil {
return nil, nil
}
document, err := prb.Documents.Get(ctx, *documentID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load vendors document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDocument(document), nil
}
// WebhookSubscriptions is the resolver for the webhookSubscriptions field.
func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.WebhookSubscriptionOrderBy) (*types.WebhookSubscriptionConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionWebhookSubscriptionList); err != nil {

View File

@@ -84,7 +84,6 @@ func NewVendor(v *coredata.Vendor) *Vendor {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
SnapshotID: v.SnapshotID,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,

View File

@@ -565,6 +565,29 @@ func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessV
}, nil
}
// PublishVendorList is the resolver for the publishVendorList field.
func (r *mutationResolver) PublishVendorList(ctx context.Context, input types.PublishVendorListInput) (*types.PublishVendorListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionVendorPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot publish vendor list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishVendorListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {

View File

@@ -63,11 +63,7 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
vendorFilter := coredata.NewVendorFilter(&noSnapshot, nil)
if input.Filter != nil {
vendorFilter = coredata.NewVendorFilter(&input.Filter.SnapshotID, nil)
}
vendorFilter := coredata.NewVendorFilter(nil)
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, vendorFilter)
if err != nil {
@@ -4840,3 +4836,19 @@ func (r *Resolver) PublishTransferImpactAssessmentListTool(ctx context.Context,
DocumentVersionID: documentVersion.ID,
}, nil
}
func (r *Resolver) PublishVendorListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishVendorListInput) (*mcp.CallToolResult, types.PublishVendorListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishVendorList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishVendorListOutput{}, fmt.Errorf("cannot publish vendor list: %w", err)
}
return nil, types.PublishVendorListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -362,15 +362,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
filter:
type: object
properties:
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only vendors with no snapshot (current live data). Pass a specific snapshot ID to retrieve vendors as they were at that snapshot.
default: null
ListVendorsOutput:
type: object
@@ -560,11 +551,6 @@ components:
- string
- "null"
description: Notes
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
created_at:
type: string
format: date-time
@@ -5372,7 +5358,6 @@ components:
type: string
enum:
- RISKS
- VENDORS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -7119,6 +7104,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishVendorListInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
PublishVendorListOutput:
type: object
required:
- document_id
- document_version_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -10396,6 +10408,14 @@ tools:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListInput"
outputSchema:
$ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput"
- name: publishVendorList
description: Publish the vendor register for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishVendorListInput"
outputSchema:
$ref: "#/components/schemas/PublishVendorListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -29,7 +29,6 @@ func NewVendorRiskAssessment(v *coredata.VendorRiskAssessment) *VendorRiskAssess
DataSensitivity: v.DataSensitivity,
BusinessImpact: v.BusinessImpact,
Notes: v.Notes,
SnapshotID: v.SnapshotID,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}

View File

@@ -64,8 +64,7 @@ func (s VendorService) ListForOrganizationId(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
var nilSnapshotID *gid.GID = nil
filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
filter := coredata.NewVendorFilter(&showOnTrustCenter)
err := vendors.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
@@ -99,8 +98,7 @@ func (s VendorService) CountForTrustCenterId(
vendors := &coredata.Vendors{}
showOnTrustCenter := true
var nilSnapshotID *gid.GID = nil
filter := coredata.NewVendorFilter(&nilSnapshotID, &showOnTrustCenter)
filter := coredata.NewVendorFilter(&showOnTrustCenter)
count, err = vendors.CountByOrganizationID(ctx, conn, s.svc.scope, trustCenter.OrganizationID, filter)
if err != nil {
return fmt.Errorf("cannot count vendors: %w", err)