Add processing activity exports

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-12-22 17:36:50 +01:00
parent 2a49c1e627
commit c76764b69a
29 changed files with 4021 additions and 226 deletions

View File

@@ -92,7 +92,7 @@ const (
ActionListComplianceReports Action = "listComplianceReports"
ActionListContacts Action = "listContacts"
ActionListContinualImprovements Action = "listContinualImprovements"
ActionListRightsRequests Action = "listRightsRequests"
ActionListRightsRequests Action = "listRightsRequests"
ActionListControls Action = "listControls"
ActionListData Action = "listData"
ActionListDocuments Action = "listDocuments"
@@ -235,39 +235,42 @@ const (
ActionDeleteVendorDataPrivacyAgreement Action = "deleteVendorDataPrivacyAgreement"
ActionDeleteVendorService Action = "deleteVendorService"
ActionAcceptInvitation Action = "acceptInvitation"
ActionAssessVendor Action = "assessVendor"
ActionAssignTask Action = "assignTask"
ActionBulkDeleteDocuments Action = "bulkDeleteDocuments"
ActionBulkExportDocuments Action = "bulkExportDocuments"
ActionBulkPublishDocumentVersions Action = "bulkPublishDocumentVersions"
ActionBulkRequestSignatures Action = "bulkRequestSignatures"
ActionCancelSignatureRequest Action = "cancelSignatureRequest"
ActionSignDocument Action = "signDocument"
ActionConfirmEmail Action = "confirmEmail"
ActionDisableSAML Action = "disableSAML"
ActionEnableSAML Action = "enableSAML"
ActionExportDocumentVersionPDF Action = "exportDocumentVersionPDF"
ActionExportSignableVersionDocumentPDF Action = "exportSignableVersionDocumentPDF"
ActionExportFramework Action = "exportFramework"
ActionGenerateDocumentChangelog Action = "generateDocumentChangelog"
ActionGenerateFrameworkStateOfApplicability Action = "generateFrameworkStateOfApplicability"
ActionImportFramework Action = "importFramework"
ActionImportMeasure Action = "importMeasure"
ActionInitiateDomainVerification Action = "initiateDomainVerification"
ActionInviteUser Action = "inviteUser"
ActionPublishDocumentVersion Action = "publishDocumentVersion"
ActionRemoveMember Action = "removeMember"
ActionRequestSignature Action = "requestSignature"
ActionSendSigningNotifications Action = "sendSigningNotifications"
ActionUnassignTask Action = "unassignTask"
ActionUploadAuditReport Action = "uploadAuditReport"
ActionUploadMeasureEvidence Action = "uploadMeasureEvidence"
ActionUploadTrustCenterNDA Action = "uploadTrustCenterNDA"
ActionUploadVendorBusinessAssociateAgreement Action = "uploadVendorBusinessAssociateAgreement"
ActionUploadVendorComplianceReport Action = "uploadVendorComplianceReport"
ActionUploadVendorDataPrivacyAgreement Action = "uploadVendorDataPrivacyAgreement"
ActionVerifyDomain Action = "verifyDomain"
ActionAcceptInvitation Action = "acceptInvitation"
ActionAssessVendor Action = "assessVendor"
ActionAssignTask Action = "assignTask"
ActionBulkDeleteDocuments Action = "bulkDeleteDocuments"
ActionBulkExportDocuments Action = "bulkExportDocuments"
ActionBulkPublishDocumentVersions Action = "bulkPublishDocumentVersions"
ActionBulkRequestSignatures Action = "bulkRequestSignatures"
ActionCancelSignatureRequest Action = "cancelSignatureRequest"
ActionSignDocument Action = "signDocument"
ActionConfirmEmail Action = "confirmEmail"
ActionDisableSAML Action = "disableSAML"
ActionEnableSAML Action = "enableSAML"
ActionExportDocumentVersionPDF Action = "exportDocumentVersionPDF"
ActionExportSignableVersionDocumentPDF Action = "exportSignableVersionDocumentPDF"
ActionExportProcessingActivitiesPDF Action = "exportProcessingActivitiesPDF"
ActionExportDataProtectionImpactAssessmentsPDF Action = "exportDataProtectionImpactAssessmentsPDF"
ActionExportTransferImpactAssessmentsPDF Action = "exportTransferImpactAssessmentsPDF"
ActionExportFramework Action = "exportFramework"
ActionGenerateDocumentChangelog Action = "generateDocumentChangelog"
ActionGenerateFrameworkStateOfApplicability Action = "generateFrameworkStateOfApplicability"
ActionImportFramework Action = "importFramework"
ActionImportMeasure Action = "importMeasure"
ActionInitiateDomainVerification Action = "initiateDomainVerification"
ActionInviteUser Action = "inviteUser"
ActionPublishDocumentVersion Action = "publishDocumentVersion"
ActionRemoveMember Action = "removeMember"
ActionRequestSignature Action = "requestSignature"
ActionSendSigningNotifications Action = "sendSigningNotifications"
ActionUnassignTask Action = "unassignTask"
ActionUploadAuditReport Action = "uploadAuditReport"
ActionUploadMeasureEvidence Action = "uploadMeasureEvidence"
ActionUploadTrustCenterNDA Action = "uploadTrustCenterNDA"
ActionUploadVendorBusinessAssociateAgreement Action = "uploadVendorBusinessAssociateAgreement"
ActionUploadVendorComplianceReport Action = "uploadVendorComplianceReport"
ActionUploadVendorDataPrivacyAgreement Action = "uploadVendorDataPrivacyAgreement"
ActionVerifyDomain Action = "verifyDomain"
)
var (
@@ -287,27 +290,30 @@ var Permissions = map[uint16]map[Action][]Role{
ActionListSignableDocuments: InternalRoles,
ActionListDocuments: NonEmployeeRoles,
ActionGetHorizontalLogoUrl: NonEmployeeRoles,
ActionPeoples: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListFrameworks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionListPeople: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListAssets: NonEmployeeRoles,
ActionListData: NonEmployeeRoles,
ActionListAudits: NonEmployeeRoles,
ActionListNonconformities: NonEmployeeRoles,
ActionListObligations: NonEmployeeRoles,
ActionListContinualImprovements: NonEmployeeRoles,
ActionListRightsRequests: NonEmployeeRoles,
ActionListProcessingActivities: NonEmployeeRoles,
ActionListSnapshots: NonEmployeeRoles,
ActionConfirmEmail: NonEmployeeRoles,
ActionAcceptInvitation: NonEmployeeRoles,
ActionListDocuments: NonEmployeeRoles,
ActionGetHorizontalLogoUrl: NonEmployeeRoles,
ActionPeoples: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListFrameworks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionListPeople: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListAssets: NonEmployeeRoles,
ActionListData: NonEmployeeRoles,
ActionListAudits: NonEmployeeRoles,
ActionListNonconformities: NonEmployeeRoles,
ActionListObligations: NonEmployeeRoles,
ActionListContinualImprovements: NonEmployeeRoles,
ActionListRightsRequests: NonEmployeeRoles,
ActionListProcessingActivities: NonEmployeeRoles,
ActionExportProcessingActivitiesPDF: NonEmployeeRoles,
ActionExportDataProtectionImpactAssessmentsPDF: NonEmployeeRoles,
ActionExportTransferImpactAssessmentsPDF: NonEmployeeRoles,
ActionListSnapshots: NonEmployeeRoles,
ActionConfirmEmail: NonEmployeeRoles,
ActionAcceptInvitation: NonEmployeeRoles,
ActionListTrustCenterFiles: CoreRoles,
ActionGetTrustCenter: CoreRoles,
@@ -694,6 +700,7 @@ var Permissions = map[uint16]map[Action][]Role{
ActionGetDataProtectionOfficer: NonEmployeeRoles,
ActionGetDataProtectionImpactAssessment: NonEmployeeRoles,
ActionGetTransferImpactAssessment: NonEmployeeRoles,
ActionExportProcessingActivitiesPDF: NonEmployeeRoles,
ActionUpdateProcessingActivity: EditRoles,
ActionDeleteProcessingActivity: EditRoles,

View File

@@ -35,6 +35,12 @@ func (e ErrDataProtectionImpactAssessmentNotFound) Error() string {
return fmt.Sprintf("data protection impact assessment not found: %q", e.Identifier)
}
type ErrNoDataProtectionImpactAssessmentsFound struct{}
func (e ErrNoDataProtectionImpactAssessmentsFound) Error() string {
return "no data protection impact assessments found"
}
type (
DataProtectionImpactAssessment struct {
ID gid.GID `db:"id"`
@@ -151,6 +157,56 @@ WHERE
return nil
}
func (dpias *DataProtectionImpactAssessments) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
filter *DataProtectionImpactAssessmentFilter,
) error {
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
description,
necessity_and_proportionality,
potential_risk,
mitigations,
residual_risk,
created_at,
updated_at
FROM
processing_activity_data_protection_impact_assessments
WHERE
%s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query data protection impact assessments: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DataProtectionImpactAssessment])
if err != nil {
return fmt.Errorf("cannot collect data protection impact assessments: %w", err)
}
*dpias = results
return nil
}
func (dpia *DataProtectionImpactAssessment) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -429,4 +485,3 @@ WHERE dpia.tenant_id = @tenant_id AND dpia.organization_id = @organization_id AN
return nil
}

View File

@@ -53,3 +53,9 @@ func (f *DataProtectionImpactAssessmentFilter) SQLFragment() string {
}
}
func (f *DataProtectionImpactAssessmentFilter) SnapshotID() *gid.GID {
if f.snapshotID == nil || *f.snapshotID == nil {
return nil
}
return *f.snapshotID
}

View File

@@ -26,6 +26,12 @@ import (
"go.probo.inc/probo/pkg/page"
)
type ErrNoProcessingActivitiesFound struct{}
func (e ErrNoProcessingActivitiesFound) Error() string {
return "no processing activities found"
}
type (
ProcessingActivity struct {
ID gid.GID `db:"id"`
@@ -231,6 +237,70 @@ WHERE
return nil
}
func (p *ProcessingActivities) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
filter *ProcessingActivityFilter,
) error {
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
data_subject_category,
personal_data_category,
special_or_criminal_data,
consent_evidence_link,
lawful_basis,
recipients,
location,
international_transfers,
transfer_safeguards,
retention_period,
security_measures,
data_protection_impact_assessment_needed,
transfer_impact_assessment_needed,
last_review_date,
next_review_date,
role,
data_protection_officer_id,
created_at,
updated_at
FROM
processing_activities
WHERE
%s
AND organization_id = @organization_id
AND %s
ORDER BY created_at DESC
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query processing activities: %w", err)
}
processingActivities, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ProcessingActivity])
if err != nil {
return fmt.Errorf("cannot collect processing activities: %w", err)
}
*p = processingActivities
return nil
}
func (p *ProcessingActivity) Insert(
ctx context.Context,
conn pg.Conn,

View File

@@ -52,3 +52,10 @@ func (f *ProcessingActivityFilter) SQLFragment() string {
return "snapshot_id = @filter_snapshot_id"
}
}
func (f *ProcessingActivityFilter) SnapshotID() *gid.GID {
if f.snapshotID == nil || *f.snapshotID == nil {
return nil
}
return *f.snapshotID
}

View File

@@ -20,10 +20,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -99,6 +99,7 @@ func (s *Snapshots) CountByOrganizationID(
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
filter *SnapshotFilter,
) (int, error) {
q := `
SELECT
@@ -108,12 +109,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2025 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 (
"time"
"github.com/jackc/pgx/v5"
)
type (
SnapshotFilter struct {
snapshotType *SnapshotsType
beforeDate *time.Time
}
)
func NewSnapshotFilter(snapshotType *SnapshotsType) *SnapshotFilter {
return &SnapshotFilter{
snapshotType: snapshotType,
}
}
func (f *SnapshotFilter) WithBeforeDate(beforeDate *time.Time) *SnapshotFilter {
f.beforeDate = beforeDate
return f
}
func (f *SnapshotFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{
"filter_snapshot_type": f.snapshotType,
"filter_before_date": f.beforeDate,
}
return args
}
func (f *SnapshotFilter) SQLFragment() string {
return `
(
CASE
WHEN @filter_snapshot_type::snapshots_type IS NOT NULL THEN
type = @filter_snapshot_type::snapshots_type
ELSE TRUE
END
AND
CASE
WHEN @filter_before_date::timestamptz IS NOT NULL THEN
created_at <= @filter_before_date::timestamptz
ELSE TRUE
END
)`
}

View File

@@ -35,6 +35,12 @@ func (e ErrTransferImpactAssessmentNotFound) Error() string {
return fmt.Sprintf("transfer impact assessment not found: %q", e.Identifier)
}
type ErrNoTransferImpactAssessmentsFound struct{}
func (e ErrNoTransferImpactAssessmentsFound) Error() string {
return "no transfer impact assessments found"
}
type (
TransferImpactAssessment struct {
ID gid.GID `db:"id"`
@@ -151,6 +157,56 @@ WHERE
return nil
}
func (tias *TransferImpactAssessments) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
filter *TransferImpactAssessmentFilter,
) error {
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
data_subjects,
legal_mechanism,
transfer,
local_law_risk,
supplementary_measures,
created_at,
updated_at
FROM
processing_activity_transfer_impact_assessments
WHERE
%s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query transfer impact assessments: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TransferImpactAssessment])
if err != nil {
return fmt.Errorf("cannot collect transfer impact assessments: %w", err)
}
*tias = results
return nil
}
func (tia *TransferImpactAssessment) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -429,4 +485,3 @@ WHERE tia.tenant_id = @tenant_id AND tia.organization_id = @organization_id AND
return nil
}

View File

@@ -53,3 +53,9 @@ func (f *TransferImpactAssessmentFilter) SQLFragment() string {
}
}
func (f *TransferImpactAssessmentFilter) SnapshotID() *gid.GID {
if f.snapshotID == nil || *f.snapshotID == nil {
return nil
}
return *f.snapshotID
}

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -850,6 +850,74 @@ WHERE %s
return nil
}
func (v *Vendors) LoadAllByProcessingActivities(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
filter *ProcessingActivityFilter,
) (map[gid.GID][]string, error) {
q := `
WITH filtered_processing_activities AS (
SELECT
pa.id
FROM
processing_activities pa
WHERE
pa.tenant_id = @tenant_id
AND pa.organization_id = @organization_id
AND %s
),
filtered_vendors AS (
SELECT
v.id,
v.name
FROM
vendors v
WHERE
v.tenant_id = @tenant_id
)
SELECT
pav.processing_activity_id,
fv.name
FROM
processing_activity_vendors pav
INNER JOIN
filtered_vendors fv ON fv.id = pav.vendor_id
INNER JOIN
filtered_processing_activities fpa ON fpa.id = pav.processing_activity_id
WHERE
pav.tenant_id = @tenant_id
ORDER BY
pav.processing_activity_id, fv.name
`
q = fmt.Sprintf(q, filter.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query vendors: %w", err)
}
defer rows.Close()
vendorMap := make(map[gid.GID][]string)
for rows.Next() {
var processingActivityID gid.GID
var vendorName string
if err := rows.Scan(&processingActivityID, &vendorName); err != nil {
return nil, fmt.Errorf("cannot scan vendor: %w", err)
}
vendorMap[processingActivityID] = append(vendorMap[processingActivityID], vendorName)
}
return vendorMap, nil
}
func (d Vendors) InsertDataSnapshots(
ctx context.Context,
conn pg.Conn,

View File

@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Protection Impact Assessments Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 250px;
object-fit: contain;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Assessment page */
.assessment-page {
page-break-before: always;
page-break-after: always;
}
.assessment-page:last-child {
page-break-after: auto;
}
.assessment-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.assessment-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.assessment-field-label {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.assessment-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
/* Annex page */
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Data Protection Impact Assessments</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-text">
This document contains Data Protection Impact Assessments (DPIAs) conducted for processing activities
that present a high risk to individuals' rights and freedoms. DPIAs are systematic assessments that
evaluate the necessity, proportionality, and risks associated with data processing operations, along
with the measures implemented to mitigate identified risks.
</div>
</div>
</div>
{{- range $index, $assessment := .Assessments}}
<div class="assessment-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$assessment.ProcessingActivityName}}</h1>
<div class="assessment-field">
<div class="assessment-field-label">Description</div>
<div class="assessment-field-value">{{if .Description}}{{.Description}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Necessity and Proportionality</div>
<div class="assessment-field-value">{{if .NecessityAndProportionality}}{{.NecessityAndProportionality}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Potential Risk</div>
<div class="assessment-field-value">{{if .PotentialRisk}}{{.PotentialRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Mitigations</div>
<div class="assessment-field-value">{{if .Mitigations}}{{.Mitigations}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Residual Risk</div>
<div class="assessment-field-value">{{if .ResidualRisk}}{{.ResidualRisk | formatResidualRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
{{- end}}
<div class="annex-page">
<h1 class="annex-title">3. Annexes</h1>
<div class="annex-section">
<div class="annex-section-title">3.1 Lexicon</div>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Residual Risk</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Low:</span>
<span class="annex-enum-description">The residual risk after implementing mitigation measures is considered low. The processing activity poses minimal risk to individuals' rights and freedoms.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Medium:</span>
<span class="annex-enum-description">The residual risk after implementing mitigation measures is considered medium. The processing activity poses a moderate risk to individuals' rights and freedoms, requiring ongoing monitoring.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">High:</span>
<span class="annex-enum-description">The residual risk after implementing mitigation measures is considered high. The processing activity poses significant risk to individuals' rights and freedoms, requiring enhanced safeguards and regular review.</span>
</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -23,21 +23,36 @@ import (
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
gmhtml "github.com/yuin/goldmark/renderer/html"
"go.probo.inc/probo/pkg/coredata"
)
const (
firstPageTOCItems = 21
otherPageTOCItems = 28
)
var (
//go:embed template.html
htmlTemplateContent string
//go:embed processing_activities_template.html
processingActivitiesTemplateContent string
//go:embed data_protection_impact_assessments_template.html
dataProtectionImpactAssessmentsTemplateContent string
//go:embed transfer_impact_assessments_template.html
transferImpactAssessmentsTemplateContent string
templateFuncs = template.FuncMap{
"now": func() time.Time { return time.Now() },
"eq": func(a, b string) bool { return a == b },
"eq": func(a, b any) bool { return a == b },
"string": func(v fmt.Stringer) string { return v.String() },
"lower": func(s string) string { return strings.ToLower(s) },
"add": func(a, b int) int { return a + b },
"classificationString": func(c Classification) string { return string(c) },
"formatContent": func(content string) template.HTML {
md := goldmark.New(
@@ -56,9 +71,111 @@ var (
"imgTag": func(src, alt, class string) template.HTML {
return template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" class="%s">`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(class)))
},
"formatLawfulBasis": func(basis coredata.ProcessingActivityLawfulBasis) string {
switch basis {
case coredata.ProcessingActivityLawfulBasisConsent:
return "Consent"
case coredata.ProcessingActivityLawfulBasisContractualNecessity:
return "Contractual Necessity"
case coredata.ProcessingActivityLawfulBasisLegalObligation:
return "Legal Obligation"
case coredata.ProcessingActivityLawfulBasisLegitimateInterest:
return "Legitimate Interest"
case coredata.ProcessingActivityLawfulBasisPublicTask:
return "Public Task"
case coredata.ProcessingActivityLawfulBasisVitalInterests:
return "Vital Interests"
default:
return basis.String()
}
},
"formatSpecialOrCriminalData": func(data coredata.ProcessingActivitySpecialOrCriminalDatum) string {
switch data {
case coredata.ProcessingActivitySpecialOrCriminalDatumYes:
return "Yes"
case coredata.ProcessingActivitySpecialOrCriminalDatumNo:
return "No"
case coredata.ProcessingActivitySpecialOrCriminalDatumPossible:
return "Possible"
default:
return data.String()
}
},
"formatTransferSafeguard": func(safeguard *coredata.ProcessingActivityTransferSafeguard) string {
if safeguard == nil {
return ""
}
switch *safeguard {
case coredata.ProcessingActivityTransferSafeguardStandardContractualClauses:
return "Standard Contractual Clauses"
case coredata.ProcessingActivityTransferSafeguardBindingCorporateRules:
return "Binding Corporate Rules"
case coredata.ProcessingActivityTransferSafeguardAdequacyDecision:
return "Adequacy Decision"
case coredata.ProcessingActivityTransferSafeguardDerogations:
return "Derogations"
case coredata.ProcessingActivityTransferSafeguardCodesOfConduct:
return "Codes of Conduct"
case coredata.ProcessingActivityTransferSafeguardCertificationMechanisms:
return "Certification Mechanisms"
default:
return safeguard.String()
}
},
"formatDPIANeeded": func(needed coredata.ProcessingActivityDataProtectionImpactAssessment) string {
switch needed {
case coredata.ProcessingActivityDataProtectionImpactAssessmentNeeded:
return "Yes"
case coredata.ProcessingActivityDataProtectionImpactAssessmentNotNeeded:
return "No"
default:
return needed.String()
}
},
"formatTIANeeded": func(needed coredata.ProcessingActivityTransferImpactAssessment) string {
switch needed {
case coredata.ProcessingActivityTransferImpactAssessmentNeeded:
return "Yes"
case coredata.ProcessingActivityTransferImpactAssessmentNotNeeded:
return "No"
default:
return needed.String()
}
},
"formatRole": func(role coredata.ProcessingActivityRole) string {
switch role {
case coredata.ProcessingActivityRoleController:
return "Controller"
case coredata.ProcessingActivityRoleProcessor:
return "Processor"
default:
return role.String()
}
},
"formatResidualRisk": func(risk *coredata.DataProtectionImpactAssessmentResidualRisk) string {
if risk == nil {
return ""
}
switch *risk {
case coredata.DataProtectionImpactAssessmentResidualRiskLow:
return "Low"
case coredata.DataProtectionImpactAssessmentResidualRiskMedium:
return "Medium"
case coredata.DataProtectionImpactAssessmentResidualRiskHigh:
return "High"
default:
return risk.String()
}
},
}
documentTemplate = template.Must(template.New("document").Funcs(templateFuncs).Parse(htmlTemplateContent))
processingActivitiesTemplate = template.Must(template.New("processingActivities").Funcs(templateFuncs).Parse(processingActivitiesTemplateContent))
dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent))
transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent))
)
type (
@@ -82,6 +199,71 @@ type (
State coredata.DocumentVersionSignatureState
RequestedAt time.Time
}
ProcessingActivityTableData struct {
CompanyName string
CompanyHorizontalLogoBase64 string
Version int
PublishedAt time.Time
Activities []ProcessingActivityRowData
}
ProcessingActivityRowData struct {
Name string
Purpose *string
DataSubjectCategory *string
PersonalDataCategory *string
SpecialOrCriminalData coredata.ProcessingActivitySpecialOrCriminalDatum
ConsentEvidenceLink *string
LawfulBasis coredata.ProcessingActivityLawfulBasis
Recipients *string
Location *string
InternationalTransfers bool
TransferSafeguards *coredata.ProcessingActivityTransferSafeguard
RetentionPeriod *string
SecurityMeasures *string
DataProtectionImpactAssessmentNeeded coredata.ProcessingActivityDataProtectionImpactAssessment
TransferImpactAssessmentNeeded coredata.ProcessingActivityTransferImpactAssessment
LastReviewDate *time.Time
NextReviewDate *time.Time
Role coredata.ProcessingActivityRole
DataProtectionOfficerFullName *string
Vendors string
}
DataProtectionImpactAssessmentTableData struct {
CompanyName string
CompanyHorizontalLogoBase64 string
Version int
PublishedAt time.Time
Assessments []DataProtectionImpactAssessmentRowData
}
DataProtectionImpactAssessmentRowData struct {
ProcessingActivityName string
Description *string
NecessityAndProportionality *string
PotentialRisk *string
Mitigations *string
ResidualRisk *coredata.DataProtectionImpactAssessmentResidualRisk
}
TransferImpactAssessmentTableData struct {
CompanyName string
CompanyHorizontalLogoBase64 string
Version int
PublishedAt time.Time
Assessments []TransferImpactAssessmentRowData
}
TransferImpactAssessmentRowData struct {
ProcessingActivityName string
DataSubjects *string
LegalMechanism *string
Transfer *string
LocalLawRisk *string
SupplementaryMeasures *string
}
)
const (
@@ -99,3 +281,30 @@ func RenderHTML(data DocumentData) ([]byte, error) {
return buf.Bytes(), nil
}
func RenderProcessingActivitiesTableHTML(data ProcessingActivityTableData) ([]byte, error) {
var buf bytes.Buffer
if err := processingActivitiesTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute processing activities template: %w", err)
}
return buf.Bytes(), nil
}
func RenderDataProtectionImpactAssessmentsTableHTML(data DataProtectionImpactAssessmentTableData) ([]byte, error) {
var buf bytes.Buffer
if err := dataProtectionImpactAssessmentsTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute data protection impact assessments template: %w", err)
}
return buf.Bytes(), nil
}
func RenderTransferImpactAssessmentsTableHTML(data TransferImpactAssessmentTableData) ([]byte, error) {
var buf bytes.Buffer
if err := transferImpactAssessmentsTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute transfer impact assessments template: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -195,7 +195,7 @@ func TestTemplateFunctions(t *testing.T) {
})
t.Run("eq function", func(t *testing.T) {
eqFunc := templateFuncs["eq"].(func(string, string) bool)
eqFunc := templateFuncs["eq"].(func(any, any) bool)
assert.True(t, eqFunc("test", "test"))
assert.False(t, eqFunc("test", "other"))
})
@@ -266,7 +266,7 @@ func TestHTMLEscaping(t *testing.T) {
resultStr := string(result)
// Verify dangerous content is escaped
// Verify dangerous content is escaped
assert.NotContains(t, resultStr, "<script>alert('xss')</script>")
assert.NotContains(t, resultStr, "<malicious>tag")
assert.Contains(t, resultStr, "&lt;script&gt;")

View File

@@ -0,0 +1,535 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Processing Activities Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 250px;
object-fit: contain;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Activity page */
.activity-page {
page-break-before: always;
page-break-after: always;
}
.activity-page:last-child {
page-break-after: auto;
}
.activity-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.activity-section {
margin-bottom: 8px;
page-break-inside: avoid;
}
.activity-section-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.activity-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.activity-field-label {
font-weight: 600;
color: #333;
margin-bottom: 3px;
font-size: 7.5pt;
}
.activity-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
.activity-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.activity-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
/* Annex page */
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Processing Activities</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-text">
This document provides a comprehensive overview of all processing activities within the organization.
It serves as a record of personal data processing operations, documenting the purposes, legal bases,
data categories, and associated safeguards for each activity.
</div>
</div>
</div>
{{- range $index, $activity := .Activities}}
<div class="activity-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$activity.Name}}</h1>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.1 General Information</div>
<div class="activity-field">
<div class="activity-field-label">Purpose</div>
<div class="activity-field-value">{{if .Purpose}}{{.Purpose}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Role</div>
<div class="activity-field-value">{{.Role | formatRole}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.2 Data Categories</div>
<div class="activity-grid-3">
<div class="activity-field">
<div class="activity-field-label">Data Subject Category</div>
<div class="activity-field-value">{{if .DataSubjectCategory}}{{.DataSubjectCategory}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Personal Data Category</div>
<div class="activity-field-value">{{if .PersonalDataCategory}}{{.PersonalDataCategory}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Special/Criminal Data</div>
<div class="activity-field-value">{{.SpecialOrCriminalData | formatSpecialOrCriminalData}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.3 Legal Basis</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Lawful Basis</div>
<div class="activity-field-value">{{.LawfulBasis | formatLawfulBasis}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Consent Evidence Link</div>
<div class="activity-field-value">{{if .ConsentEvidenceLink}}{{.ConsentEvidenceLink}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.4 Data Sharing & Transfers</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Recipients</div>
<div class="activity-field-value">{{if .Recipients}}{{.Recipients}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Location</div>
<div class="activity-field-value">{{if .Location}}{{.Location}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">International Transfers</div>
<div class="activity-field-value">{{if .InternationalTransfers}}Yes{{else}}No{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Transfer Safeguards</div>
<div class="activity-field-value">{{if .TransferSafeguards}}{{.TransferSafeguards | formatTransferSafeguard}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.5 Retention & Security</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Retention Period</div>
<div class="activity-field-value">{{if .RetentionPeriod}}{{.RetentionPeriod}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Security Measures</div>
<div class="activity-field-value">{{if .SecurityMeasures}}{{.SecurityMeasures}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.6 Assessments & Reviews</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">DPIA Needed</div>
<div class="activity-field-value">{{.DataProtectionImpactAssessmentNeeded | formatDPIANeeded}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">TIA Needed</div>
<div class="activity-field-value">{{.TransferImpactAssessmentNeeded | formatTIANeeded}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Last Review Date</div>
<div class="activity-field-value">{{if .LastReviewDate}}{{.LastReviewDate.Format "January 2, 2006"}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Next Review Date</div>
<div class="activity-field-value">{{if .NextReviewDate}}{{.NextReviewDate.Format "January 2, 2006"}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.7 Responsible Parties</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Data Protection Officer</div>
<div class="activity-field-value">{{if .DataProtectionOfficerFullName}}{{.DataProtectionOfficerFullName}}{{else}}<span class="empty-value">Not assigned</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Vendors</div>
<div class="activity-field-value">{{if .Vendors}}{{.Vendors}}{{else}}<span class="empty-value">None</span>{{end}}</div>
</div>
</div>
</div>
</div>
{{- end}}
<div class="annex-page">
<h1 class="annex-title">3. Annexes</h1>
<div class="annex-section">
<div class="annex-section-title">3.1 Lexicon</div>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Role</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Controller:</span>
<span class="annex-enum-description">The entity that determines the purposes and means of processing personal data.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Processor:</span>
<span class="annex-enum-description">The entity that processes personal data on behalf of the controller.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Lawful Basis</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Consent:</span>
<span class="annex-enum-description">The data subject has given consent to the processing of their personal data.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Contractual Necessity:</span>
<span class="annex-enum-description">Processing is necessary for the performance of a contract to which the data subject is party.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Legal Obligation:</span>
<span class="annex-enum-description">Processing is necessary for compliance with a legal obligation to which the controller is subject.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Legitimate Interest:</span>
<span class="annex-enum-description">Processing is necessary for the purposes of the legitimate interests pursued by the controller or a third party.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Public Task:</span>
<span class="annex-enum-description">Processing is necessary for the performance of a task carried out in the public interest or in the exercise of official authority.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Vital Interests:</span>
<span class="annex-enum-description">Processing is necessary to protect the vital interests of the data subject or of another natural person.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Special/Criminal Data</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The processing activity involves special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The processing activity does not involve special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Possible:</span>
<span class="annex-enum-description">The processing activity may involve special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Transfer Safeguards</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Standard Contractual Clauses:</span>
<span class="annex-enum-description">Standard contractual clauses approved by the European Commission are used to ensure adequate protection for international transfers.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Binding Corporate Rules:</span>
<span class="annex-enum-description">Binding corporate rules approved by a supervisory authority are used to ensure adequate protection for international transfers.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Adequacy Decision:</span>
<span class="annex-enum-description">The European Commission has determined that the third country ensures an adequate level of protection.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Derogations:</span>
<span class="annex-enum-description">A derogation under Article 49 of the GDPR is used for the international transfer.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Codes of Conduct:</span>
<span class="annex-enum-description">An approved code of conduct together with binding and enforceable commitments is used to ensure adequate protection.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Certification Mechanisms:</span>
<span class="annex-enum-description">An approved certification mechanism together with binding and enforceable commitments is used to ensure adequate protection.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">DPIA Needed</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">A Data Protection Impact Assessment is required for this processing activity.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">A Data Protection Impact Assessment is not required for this processing activity.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">TIA Needed</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">A Transfer Impact Assessment is required for this processing activity.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">A Transfer Impact Assessment is not required for this processing activity.</span>
</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transfer Impact Assessments Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 250px;
object-fit: contain;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Assessment page */
.assessment-page {
page-break-before: always;
page-break-after: always;
}
.assessment-page:last-child {
page-break-after: auto;
}
.assessment-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.assessment-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.assessment-field-label {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.assessment-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Transfer Impact Assessments</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-text">
This document contains Transfer Impact Assessments (TIAs) conducted for processing activities involving
international transfers of personal data to countries outside the European Economic Area (EEA). TIAs
evaluate the legal mechanisms used for transfers, assess risks related to local laws in destination
countries, and document supplementary measures implemented to ensure an adequate level of data protection.
</div>
</div>
</div>
{{- range $index, $assessment := .Assessments}}
<div class="assessment-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$assessment.ProcessingActivityName}}</h1>
<div class="assessment-field">
<div class="assessment-field-label">Data Subjects</div>
<div class="assessment-field-value">{{if .DataSubjects}}{{.DataSubjects}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Transfer</div>
<div class="assessment-field-value">{{if .Transfer}}{{.Transfer}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Legal Mechanism</div>
<div class="assessment-field-value">{{if .LegalMechanism}}{{.LegalMechanism}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Local Law Risk</div>
<div class="assessment-field-value">{{if .LocalLawRisk}}{{.LocalLawRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Supplementary Measures</div>
<div class="assessment-field-value">{{if .SupplementaryMeasures}}{{.SupplementaryMeasures}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
{{- end}}
</body>
</html>

View File

@@ -17,17 +17,21 @@ package probo
import (
"context"
"fmt"
"io"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type DataProtectionImpactAssessmentService struct {
svc *TenantService
svc *TenantService
html2pdfConverter *html2pdf.Converter
}
type (
@@ -298,3 +302,130 @@ func (s *DataProtectionImpactAssessmentService) Delete(
return err
}
func (s *DataProtectionImpactAssessmentService) ExportPDF(
ctx context.Context,
organizationID gid.GID,
filter *coredata.DataProtectionImpactAssessmentFilter,
) ([]byte, error) {
var tableData docgen.DataProtectionImpactAssessmentTableData
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var assessments coredata.DataProtectionImpactAssessments
if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil {
return fmt.Errorf("cannot load data protection impact assessments: %w", err)
}
if len(assessments) == 0 {
return &coredata.ErrNoDataProtectionImpactAssessmentsFound{}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
horizontalLogoBase64 := ""
if organization.HorizontalLogoFileID != nil {
fileRecord := &coredata.File{}
fileErr := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID)
})
if fileErr == nil {
base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord)
if logoErr == nil {
horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
}
}
}
var snapshots coredata.Snapshots
snapshotType := coredata.SnapshotsTypeProcessingActivities
var version int
var publishedAt time.Time
if snapshotID := filter.SnapshotID(); snapshotID != nil {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
publishedAt = snapshot.CreatedAt
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount
} else {
publishedAt = time.Now()
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount + 1
}
assessmentRows := make([]docgen.DataProtectionImpactAssessmentRowData, len(assessments))
for i, assessment := range assessments {
processingActivity := &coredata.ProcessingActivity{}
if err := processingActivity.LoadByID(ctx, conn, s.svc.scope, assessment.ProcessingActivityID); err != nil {
return fmt.Errorf("cannot load processing activity: %w", err)
}
assessmentRows[i] = docgen.DataProtectionImpactAssessmentRowData{
ProcessingActivityName: processingActivity.Name,
Description: assessment.Description,
NecessityAndProportionality: assessment.NecessityAndProportionality,
PotentialRisk: assessment.PotentialRisk,
Mitigations: assessment.Mitigations,
ResidualRisk: assessment.ResidualRisk,
}
}
tableData = docgen.DataProtectionImpactAssessmentTableData{
CompanyName: organization.Name,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Version: version,
PublishedAt: publishedAt,
Assessments: assessmentRows,
}
return nil
},
)
if err != nil {
return nil, err
}
htmlContent, err := docgen.RenderDataProtectionImpactAssessmentsTableHTML(tableData)
if err != nil {
return nil, fmt.Errorf("cannot render HTML: %w", err)
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(0.98),
MarginBottom: html2pdf.NewMarginInches(0.98),
MarginLeft: html2pdf.NewMarginInches(0.98),
MarginRight: html2pdf.NewMarginInches(0.98),
PrintBackground: true,
Scale: 1.0,
}
pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg)
if err != nil {
return nil, fmt.Errorf("cannot generate PDF: %w", err)
}
pdfData, err := io.ReadAll(pdfReader)
if err != nil {
return nil, fmt.Errorf("cannot read PDF data: %w", err)
}
return pdfData, nil
}

View File

@@ -17,17 +17,22 @@ package probo
import (
"context"
"fmt"
"io"
"strings"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type ProcessingActivityService struct {
svc *TenantService
svc *TenantService
html2pdfConverter *html2pdf.Converter
}
type (
@@ -382,3 +387,159 @@ func (s ProcessingActivityService) CountForOrganizationID(
return count, nil
}
func (s *ProcessingActivityService) ExportPDF(
ctx context.Context,
organizationID gid.GID,
filter *coredata.ProcessingActivityFilter,
) ([]byte, error) {
var tableData docgen.ProcessingActivityTableData
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var processingActivities coredata.ProcessingActivities
if err := processingActivities.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil {
return fmt.Errorf("cannot load processing activities: %w", err)
}
if len(processingActivities) == 0 {
return &coredata.ErrNoProcessingActivitiesFound{}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
horizontalLogoBase64 := ""
if organization.HorizontalLogoFileID != nil {
fileRecord := &coredata.File{}
fileErr := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID)
})
if fileErr == nil {
base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord)
if logoErr == nil {
horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
}
}
}
var vendors coredata.Vendors
vendorMap, err := vendors.LoadAllByProcessingActivities(ctx, conn, s.svc.scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot load vendors: %w", err)
}
var snapshots coredata.Snapshots
snapshotType := coredata.SnapshotsTypeProcessingActivities
var version int
var publishedAt time.Time
if snapshotID := filter.SnapshotID(); snapshotID != nil {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
publishedAt = snapshot.CreatedAt
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount
} else {
publishedAt = time.Now()
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount + 1
}
activities := make([]docgen.ProcessingActivityRowData, len(processingActivities))
for i, pa := range processingActivities {
dpoFullName := (*string)(nil)
if pa.DataProtectionOfficerID != nil {
dpo := &coredata.People{}
if err := dpo.LoadByID(ctx, conn, s.svc.scope, *pa.DataProtectionOfficerID); err == nil {
dpoFullName = &dpo.FullName
}
}
vendorsList := ""
if vendorNames, ok := vendorMap[pa.ID]; ok && len(vendorNames) > 0 {
vendorsList = strings.Join(vendorNames, ", ")
}
activities[i] = docgen.ProcessingActivityRowData{
Name: pa.Name,
Purpose: pa.Purpose,
DataSubjectCategory: pa.DataSubjectCategory,
PersonalDataCategory: pa.PersonalDataCategory,
SpecialOrCriminalData: pa.SpecialOrCriminalData,
ConsentEvidenceLink: pa.ConsentEvidenceLink,
LawfulBasis: pa.LawfulBasis,
Recipients: pa.Recipients,
Location: pa.Location,
InternationalTransfers: pa.InternationalTransfers,
TransferSafeguards: pa.TransferSafeguard,
RetentionPeriod: pa.RetentionPeriod,
SecurityMeasures: pa.SecurityMeasures,
DataProtectionImpactAssessmentNeeded: pa.DataProtectionImpactAssessmentNeeded,
TransferImpactAssessmentNeeded: pa.TransferImpactAssessmentNeeded,
LastReviewDate: pa.LastReviewDate,
NextReviewDate: pa.NextReviewDate,
Role: pa.Role,
DataProtectionOfficerFullName: dpoFullName,
Vendors: vendorsList,
}
}
tableData = docgen.ProcessingActivityTableData{
CompanyName: organization.Name,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Version: version,
PublishedAt: publishedAt,
Activities: activities,
}
return nil
},
)
if err != nil {
return nil, err
}
htmlContent, err := docgen.RenderProcessingActivitiesTableHTML(tableData)
if err != nil {
return nil, fmt.Errorf("cannot generate HTML: %w", err)
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(0.98),
MarginBottom: html2pdf.NewMarginInches(0.98),
MarginLeft: html2pdf.NewMarginInches(0.98),
MarginRight: html2pdf.NewMarginInches(0.98),
PrintBackground: true,
Scale: 1.0,
}
pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg)
if err != nil {
return nil, fmt.Errorf("cannot generate PDF: %w", err)
}
pdfData, err := io.ReadAll(pdfReader)
if err != nil {
return nil, fmt.Errorf("cannot read PDF data: %w", err)
}
return pdfData, nil
}

View File

@@ -252,9 +252,18 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Snapshots = &SnapshotService{svc: tenantService}
tenantService.ContinualImprovements = &ContinualImprovementService{svc: tenantService}
tenantService.RightsRequests = &RightsRequestService{svc: tenantService}
tenantService.ProcessingActivities = &ProcessingActivityService{svc: tenantService}
tenantService.DataProtectionImpactAssessments = &DataProtectionImpactAssessmentService{svc: tenantService}
tenantService.TransferImpactAssessments = &TransferImpactAssessmentService{svc: tenantService}
tenantService.ProcessingActivities = &ProcessingActivityService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
}
tenantService.DataProtectionImpactAssessments = &DataProtectionImpactAssessmentService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
}
tenantService.TransferImpactAssessments = &TransferImpactAssessmentService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
}
tenantService.Files = &FileService{svc: tenantService}
tenantService.CustomDomains = &CustomDomainService{
svc: tenantService,

View File

@@ -192,7 +192,8 @@ func (s *SnapshotService) CountForOrganizationID(
ctx,
func(conn pg.Conn) (err error) {
snapshots := coredata.Snapshots{}
count, err = snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
filter := coredata.NewSnapshotFilter(nil)
count, err = snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count snapshots: %w", err)
}

View File

@@ -17,17 +17,21 @@ package probo
import (
"context"
"fmt"
"io"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type TransferImpactAssessmentService struct {
svc *TenantService
svc *TenantService
html2pdfConverter *html2pdf.Converter
}
type (
@@ -298,3 +302,130 @@ func (s *TransferImpactAssessmentService) Delete(
return err
}
func (s *TransferImpactAssessmentService) ExportPDF(
ctx context.Context,
organizationID gid.GID,
filter *coredata.TransferImpactAssessmentFilter,
) ([]byte, error) {
var tableData docgen.TransferImpactAssessmentTableData
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var assessments coredata.TransferImpactAssessments
if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil {
return fmt.Errorf("cannot load transfer impact assessments: %w", err)
}
if len(assessments) == 0 {
return &coredata.ErrNoTransferImpactAssessmentsFound{}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
horizontalLogoBase64 := ""
if organization.HorizontalLogoFileID != nil {
fileRecord := &coredata.File{}
fileErr := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID)
})
if fileErr == nil {
base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord)
if logoErr == nil {
horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
}
}
}
var snapshots coredata.Snapshots
snapshotType := coredata.SnapshotsTypeProcessingActivities
var version int
var publishedAt time.Time
if snapshotID := filter.SnapshotID(); snapshotID != nil {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
publishedAt = snapshot.CreatedAt
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount
} else {
publishedAt = time.Now()
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType)
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter)
if err != nil {
return fmt.Errorf("cannot count processing activities snapshots: %w", err)
}
version = snapshotCount + 1
}
assessmentRows := make([]docgen.TransferImpactAssessmentRowData, len(assessments))
for i, assessment := range assessments {
processingActivity := &coredata.ProcessingActivity{}
if err := processingActivity.LoadByID(ctx, conn, s.svc.scope, assessment.ProcessingActivityID); err != nil {
return fmt.Errorf("cannot load processing activity: %w", err)
}
assessmentRows[i] = docgen.TransferImpactAssessmentRowData{
ProcessingActivityName: processingActivity.Name,
DataSubjects: assessment.DataSubjects,
LegalMechanism: assessment.LegalMechanism,
Transfer: assessment.Transfer,
LocalLawRisk: assessment.LocalLawRisk,
SupplementaryMeasures: assessment.SupplementaryMeasures,
}
}
tableData = docgen.TransferImpactAssessmentTableData{
CompanyName: organization.Name,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Version: version,
PublishedAt: publishedAt,
Assessments: assessmentRows,
}
return nil
},
)
if err != nil {
return nil, err
}
htmlContent, err := docgen.RenderTransferImpactAssessmentsTableHTML(tableData)
if err != nil {
return nil, fmt.Errorf("cannot render HTML: %w", err)
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(0.98),
MarginBottom: html2pdf.NewMarginInches(0.98),
MarginLeft: html2pdf.NewMarginInches(0.98),
MarginRight: html2pdf.NewMarginInches(0.98),
PrintBackground: true,
Scale: 1.0,
}
pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg)
if err != nil {
return nil, fmt.Errorf("cannot generate PDF: %w", err)
}
pdfData, err := io.ReadAll(pdfReader)
if err != nil {
return nil, fmt.Errorf("cannot read PDF data: %w", err)
}
return pdfData, nil
}

View File

@@ -3310,6 +3310,15 @@ type Mutation {
exportSignableVersionDocumentPDF(
input: ExportSignableDocumentVersionPDFInput!
): ExportSignableDocumentVersionPDFPayload!
exportProcessingActivitiesPDF(
input: ExportProcessingActivitiesPDFInput!
): ExportProcessingActivitiesPDFPayload!
exportDataProtectionImpactAssessmentsPDF(
input: ExportDataProtectionImpactAssessmentsPDFInput!
): ExportDataProtectionImpactAssessmentsPDFPayload!
exportTransferImpactAssessmentsPDF(
input: ExportTransferImpactAssessmentsPDFInput!
): ExportTransferImpactAssessmentsPDFPayload!
createVendorRiskAssessment(
input: CreateVendorRiskAssessmentInput!
): CreateVendorRiskAssessmentPayload!
@@ -3941,6 +3950,21 @@ input ExportSignableDocumentVersionPDFInput {
documentVersionId: ID!
}
input ExportProcessingActivitiesPDFInput {
organizationId: ID!
filter: ProcessingActivityFilter
}
input ExportDataProtectionImpactAssessmentsPDFInput {
organizationId: ID!
filter: DataProtectionImpactAssessmentFilter
}
input ExportTransferImpactAssessmentsPDFInput {
organizationId: ID!
filter: TransferImpactAssessmentFilter
}
input DeleteDocumentInput {
documentId: ID!
}
@@ -4600,6 +4624,18 @@ type ExportSignableDocumentVersionPDFPayload {
data: String!
}
type ExportProcessingActivitiesPDFPayload {
data: String!
}
type ExportDataProtectionImpactAssessmentsPDFPayload {
data: String!
}
type ExportTransferImpactAssessmentsPDFPayload {
data: String!
}
type UpdateDocumentPayload {
document: Document!
}

File diff suppressed because it is too large Load Diff

View File

@@ -1220,6 +1220,15 @@ type EvidenceEdge struct {
Node *Evidence `json:"node"`
}
type ExportDataProtectionImpactAssessmentsPDFInput struct {
OrganizationID gid.GID `json:"organizationId"`
Filter *DataProtectionImpactAssessmentFilter `json:"filter,omitempty"`
}
type ExportDataProtectionImpactAssessmentsPDFPayload struct {
Data string `json:"data"`
}
type ExportDocumentVersionPDFInput struct {
DocumentVersionID gid.GID `json:"documentVersionId"`
WithWatermark bool `json:"withWatermark"`
@@ -1239,6 +1248,15 @@ type ExportFrameworkPayload struct {
ExportJobID gid.GID `json:"exportJobId"`
}
type ExportProcessingActivitiesPDFInput struct {
OrganizationID gid.GID `json:"organizationId"`
Filter *ProcessingActivityFilter `json:"filter,omitempty"`
}
type ExportProcessingActivitiesPDFPayload struct {
Data string `json:"data"`
}
type ExportSignableDocumentVersionPDFInput struct {
DocumentVersionID gid.GID `json:"documentVersionId"`
}
@@ -1247,6 +1265,15 @@ type ExportSignableDocumentVersionPDFPayload struct {
Data string `json:"data"`
}
type ExportTransferImpactAssessmentsPDFInput struct {
OrganizationID gid.GID `json:"organizationId"`
Filter *TransferImpactAssessmentFilter `json:"filter,omitempty"`
}
type ExportTransferImpactAssessmentsPDFPayload struct {
Data string `json:"data"`
}
type File struct {
ID gid.GID `json:"id"`
MimeType string `json:"mimeType"`

View File

@@ -3745,6 +3745,84 @@ func (r *mutationResolver) ExportSignableVersionDocumentPDF(ctx context.Context,
}, nil
}
// ExportProcessingActivitiesPDF is the resolver for the exportProcessingActivitiesPDF field.
func (r *mutationResolver) ExportProcessingActivitiesPDF(ctx context.Context, input types.ExportProcessingActivitiesPDFInput) (*types.ExportProcessingActivitiesPDFPayload, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionExportProcessingActivitiesPDF)
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
var snapshotIDPtr *gid.GID
if input.Filter != nil {
snapshotIDPtr = input.Filter.SnapshotID
}
processingActivityFilter := coredata.NewProcessingActivityFilter(&snapshotIDPtr)
pdf, err := prb.ProcessingActivities.ExportPDF(ctx, input.OrganizationID, processingActivityFilter)
if err != nil {
var errNotFound *coredata.ErrNoProcessingActivitiesFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot export processing activities PDF: %w", err))
}
return &types.ExportProcessingActivitiesPDFPayload{
Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)),
}, nil
}
// ExportDataProtectionImpactAssessmentsPDF is the resolver for the exportDataProtectionImpactAssessmentsPDF field.
func (r *mutationResolver) ExportDataProtectionImpactAssessmentsPDF(ctx context.Context, input types.ExportDataProtectionImpactAssessmentsPDFInput) (*types.ExportDataProtectionImpactAssessmentsPDFPayload, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionExportDataProtectionImpactAssessmentsPDF)
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
var snapshotIDPtr *gid.GID
if input.Filter != nil {
snapshotIDPtr = input.Filter.SnapshotID
}
dpiaFilter := coredata.NewDataProtectionImpactAssessmentFilter(&snapshotIDPtr)
pdf, err := prb.DataProtectionImpactAssessments.ExportPDF(ctx, input.OrganizationID, dpiaFilter)
if err != nil {
var errNotFound *coredata.ErrNoDataProtectionImpactAssessmentsFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot export data protection impact assessments PDF: %w", err))
}
return &types.ExportDataProtectionImpactAssessmentsPDFPayload{
Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)),
}, nil
}
// ExportTransferImpactAssessmentsPDF is the resolver for the exportTransferImpactAssessmentsPDF field.
func (r *mutationResolver) ExportTransferImpactAssessmentsPDF(ctx context.Context, input types.ExportTransferImpactAssessmentsPDFInput) (*types.ExportTransferImpactAssessmentsPDFPayload, error) {
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionExportTransferImpactAssessmentsPDF)
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
var snapshotIDPtr *gid.GID
if input.Filter != nil {
snapshotIDPtr = input.Filter.SnapshotID
}
tiaFilter := coredata.NewTransferImpactAssessmentFilter(&snapshotIDPtr)
pdf, err := prb.TransferImpactAssessments.ExportPDF(ctx, input.OrganizationID, tiaFilter)
if err != nil {
var errNotFound *coredata.ErrNoTransferImpactAssessmentsFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot export transfer impact assessments PDF: %w", err))
}
return &types.ExportTransferImpactAssessmentsPDFPayload{
Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)),
}, nil
}
// CreateVendorRiskAssessment is the resolver for the createVendorRiskAssessment field.
func (r *mutationResolver) CreateVendorRiskAssessment(ctx context.Context, input types.CreateVendorRiskAssessmentInput) (*types.CreateVendorRiskAssessmentPayload, error) {
r.MustBeAuthorized(ctx, input.VendorID, authz.ActionCreateVendorRiskAssessment)