Add trust center front

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-07-28 17:24:16 +02:00
parent 2ac8f31492
commit cb3d79502d
72 changed files with 18712 additions and 82 deletions

View File

@@ -142,6 +142,7 @@ func (a *Audits) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[AuditOrderField],
filter *AuditFilter,
) error {
q := `
SELECT
@@ -161,12 +162,14 @@ WHERE
%s
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)

View File

@@ -0,0 +1,54 @@
// 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 (
"github.com/jackc/pgx/v5"
)
type (
AuditFilter struct {
showOnTrustCenter *bool
}
)
func NewAuditFilter() *AuditFilter {
return &AuditFilter{}
}
func NewAuditTrustCenterFilter() *AuditFilter {
showOnTrustCenter := true
return &AuditFilter{
showOnTrustCenter: &showOnTrustCenter,
}
}
func (f *AuditFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.showOnTrustCenter != nil {
args["show_on_trust_center"] = *f.showOnTrustCenter
}
return args
}
func (f *AuditFilter) SQLFragment() string {
if f.showOnTrustCenter != nil {
return "show_on_trust_center = @show_on_trust_center"
}
return "TRUE"
}

View File

@@ -20,7 +20,8 @@ import (
type (
DocumentFilter struct {
query *string
query *string
showOnTrustCenter *bool
}
)
@@ -30,21 +31,52 @@ func NewDocumentFilter(query *string) *DocumentFilter {
}
}
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
return pgx.NamedArgs{
"query": f.query,
func NewDocumentTrustCenterFilter() *DocumentFilter {
showOnTrustCenter := true
return &DocumentFilter{
showOnTrustCenter: &showOnTrustCenter,
}
}
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.query != nil {
args["query"] = *f.query
}
if f.showOnTrustCenter != nil {
args["show_on_trust_center"] = *f.showOnTrustCenter
}
return args
}
func (f *DocumentFilter) SQLFragment() string {
if f.query == nil || *f.query == "" {
return "TRUE"
}
conditions := []string{}
return `
if f.query != nil && *f.query != "" {
conditions = append(conditions, `
search_vector @@ (
SELECT to_tsquery('simple', string_agg(lexeme || ':*', ' & '))
FROM unnest(regexp_split_to_array(trim(@query), '\s+')) AS lexeme
)
`
)`)
}
if f.showOnTrustCenter != nil {
conditions = append(conditions, "show_on_trust_center = @show_on_trust_center")
}
if len(conditions) == 0 {
return "TRUE"
}
result := ""
for i, condition := range conditions {
if i > 0 {
result += " AND "
}
result += condition
}
return result
}

View File

@@ -38,4 +38,5 @@ const (
AuditEntityType
ReportEntityType
TrustCenterEntityType
TrustCenterAccessEntityType
)

View File

@@ -0,0 +1,11 @@
CREATE TABLE trust_center_accesses (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
trust_center_id TEXT NOT NULL REFERENCES trust_centers(id) ON DELETE CASCADE,
email CITEXT NOT NULL,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(trust_center_id, email)
);

View File

@@ -135,6 +135,44 @@ LIMIT 1;
return nil
}
func (tc *TrustCenter) LoadBySlug(
ctx context.Context,
conn pg.Conn,
slug string,
) error {
q := `
SELECT
id,
organization_id,
tenant_id,
active,
slug,
created_at,
updated_at
FROM
trust_centers
WHERE
slug = @slug
LIMIT 1;
`
args := pgx.StrictNamedArgs{"slug": slug}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query trust center: %w", err)
}
trustCenter, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenter])
if err != nil {
return fmt.Errorf("cannot collect trust center: %w", err)
}
*tc = trustCenter
return nil
}
func (tc *TrustCenter) Insert(
ctx context.Context,
conn pg.Conn,

View File

@@ -0,0 +1,318 @@
// 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 (
"context"
"fmt"
"maps"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
TrustCenterAccess struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
TrustCenterID gid.GID `db:"trust_center_id"`
Email string `db:"email"`
Name string `db:"name"`
Active bool `db:"active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
TrustCenterAccesses []*TrustCenterAccess
)
func (tca *TrustCenterAccess) CursorKey(orderBy TrustCenterAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterAccessOrderFieldCreatedAt:
return page.NewCursorKey(tca.ID, tca.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (tca *TrustCenterAccess) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
accessID gid.GID,
) error {
q := `
SELECT
id,
tenant_id,
trust_center_id,
email,
name,
active,
created_at,
updated_at
FROM
trust_center_accesses
WHERE
%s
AND id = @access_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"access_id": accessID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query trust center access: %w", err)
}
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
return fmt.Errorf("cannot collect trust center access: %w", err)
}
*tca = access
return nil
}
func (tca *TrustCenterAccess) LoadByTrustCenterIDAndEmail(
ctx context.Context,
conn pg.Conn,
scope Scoper,
trustCenterID gid.GID,
email string,
) error {
q := `
SELECT
id,
tenant_id,
trust_center_id,
email,
name,
active,
created_at,
updated_at
FROM
trust_center_accesses
WHERE
%s
AND trust_center_id = @trust_center_id
AND email = @email
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"trust_center_id": trustCenterID,
"email": email,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query trust center access: %w", err)
}
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
return fmt.Errorf("cannot collect trust center access: %w", err)
}
*tca = access
return nil
}
func (tca *TrustCenterAccess) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO trust_center_accesses (
id,
tenant_id,
trust_center_id,
email,
name,
active,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@trust_center_id,
@email,
@name,
@active,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"id": tca.ID,
"tenant_id": tca.TenantID,
"trust_center_id": tca.TrustCenterID,
"email": tca.Email,
"name": tca.Name,
"active": tca.Active,
"created_at": tca.CreatedAt,
"updated_at": tca.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert trust center access: %w", err)
}
return nil
}
func (tca *TrustCenterAccess) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE trust_center_accesses
SET
active = @active,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": tca.ID,
"active": tca.Active,
"updated_at": tca.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update trust center access: %w", err)
}
return nil
}
func (tca *TrustCenterAccess) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
DELETE FROM trust_center_accesses
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": tca.ID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete trust center access: %w", err)
}
return nil
}
func (tcas *TrustCenterAccesses) LoadByTrustCenterID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[TrustCenterAccessOrderField],
) error {
q := `
SELECT
id,
tenant_id,
trust_center_id,
email,
name,
active,
created_at,
updated_at
FROM
trust_center_accesses
WHERE
%s
AND trust_center_id = @trust_center_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"trust_center_id": trustCenterID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query trust center accesses: %w", err)
}
accesses, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrustCenterAccess])
if err != nil {
return fmt.Errorf("cannot collect trust center accesses: %w", err)
}
*tcas = accesses
return nil
}
type (
TrustCenterAccessOrderField string
)
const (
TrustCenterAccessOrderFieldCreatedAt TrustCenterAccessOrderField = "CREATED_AT"
)
func (tcaof TrustCenterAccessOrderField) String() string {
return string(tcaof)
}
func (tcaof TrustCenterAccessOrderField) Column() string {
switch tcaof {
case TrustCenterAccessOrderFieldCreatedAt:
return "created_at"
}
panic(fmt.Sprintf("unsupported order by: %s", tcaof))
}

View File

@@ -278,45 +278,48 @@ func (v *Vendors) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[VendorOrderField],
filter *VendorFilter,
) 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,
business_owner_id,
security_owner_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
created_at,
updated_at
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,
business_owner_id,
security_owner_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
created_at,
updated_at
FROM
vendors
vendors
WHERE
%s
AND organization_id = @organization_id
AND %s
%s
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, cursor.SQLArguments())
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {

View File

@@ -0,0 +1,54 @@
// 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 (
"github.com/jackc/pgx/v5"
)
type (
VendorFilter struct {
showOnTrustCenter *bool
}
)
func NewVendorFilter() *VendorFilter {
return &VendorFilter{}
}
func NewVendorTrustCenterFilter() *VendorFilter {
showOnTrustCenter := true
return &VendorFilter{
showOnTrustCenter: &showOnTrustCenter,
}
}
func (f *VendorFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.showOnTrustCenter != nil {
args["show_on_trust_center"] = *f.showOnTrustCenter
}
return args
}
func (f *VendorFilter) SQLFragment() string {
if f.showOnTrustCenter != nil {
return "show_on_trust_center = @show_on_trust_center"
}
return "TRUE"
}

View File

@@ -201,7 +201,8 @@ func (s AuditService) ListForOrganizationID(
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := audits.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor)
filter := coredata.NewAuditFilter()
err := audits.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load audits: %w", err)
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/getprobo/probo/pkg/filevalidation"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
@@ -38,6 +39,7 @@ type (
tokenSecret string
agentConfig agents.Config
html2pdfConverter *html2pdf.Converter
usrmgr *usrmgr.Service
}
TenantService struct {
@@ -66,6 +68,7 @@ type (
Audits *AuditService
Reports *ReportService
TrustCenters *TrustCenterService
TrustCenterAccesses *TrustCenterAccessService
}
)
@@ -79,6 +82,7 @@ func NewService(
tokenSecret string,
agentConfig agents.Config,
html2pdfConverter *html2pdf.Converter,
usrmgrService *usrmgr.Service,
) (*Service, error) {
if bucket == "" {
return nil, fmt.Errorf("bucket is required")
@@ -93,11 +97,16 @@ func NewService(
tokenSecret: tokenSecret,
agentConfig: agentConfig,
html2pdfConverter: html2pdfConverter,
usrmgr: usrmgrService,
}
return svc, nil
}
func (s *Service) GetEncryptionKey() cipher.EncryptionKey {
return s.encryptionKey
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService := &TenantService{
pg: s.pg,
@@ -146,5 +155,9 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Audits = &AuditService{svc: tenantService}
tenantService.Reports = &ReportService{svc: tenantService}
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{
svc: tenantService,
usrmgr: s.usrmgr,
}
return tenantService
}

View File

@@ -0,0 +1,341 @@
// 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 probo
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
type (
TrustCenterAccessService struct {
svc *TenantService
usrmgr *usrmgr.Service
}
CreateTrustCenterAccessRequest struct {
TrustCenterID gid.GID
Email string
Name string
SendEmail bool
}
UpdateTrustCenterAccessRequest struct {
AccessID gid.GID
Email *string
Name *string
Active *bool
SendEmail bool
}
DeleteTrustCenterAccessRequest struct {
AccessID gid.GID
}
RevokeTrustCenterAccessRequest struct {
AccessID gid.GID
}
TrustCenterAccessData struct {
TrustCenterID gid.GID `json:"trust_center_id"`
Email string `json:"email"`
}
)
const (
TokenTypeTrustCenterAccess = "trust_center_access"
)
func (s TrustCenterAccessService) RevokeAccess(
ctx context.Context,
req *RevokeTrustCenterAccessRequest,
) (*coredata.TrustCenterAccess, error) {
access := &coredata.TrustCenterAccess{}
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := access.LoadByID(ctx, tx, s.svc.scope, req.AccessID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
access.Active = false
access.UpdatedAt = time.Now()
if err := access.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update trust center access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return access, nil
}
func (s TrustCenterAccessService) ListForTrustCenterID(
ctx context.Context,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.TrustCenterAccessOrderField],
) (*page.Page[*coredata.TrustCenterAccess, coredata.TrustCenterAccessOrderField], error) {
var accesses coredata.TrustCenterAccesses
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return accesses.LoadByTrustCenterID(ctx, conn, s.svc.scope, trustCenterID, cursor)
})
if err != nil {
return nil, err
}
return page.NewPage(accesses, cursor), nil
}
func (s TrustCenterAccessService) ValidateToken(
ctx context.Context,
tokenString string,
) (*TrustCenterAccessData, error) {
token, err := statelesstoken.ValidateToken[TrustCenterAccessData](
s.svc.tokenSecret,
TokenTypeTrustCenterAccess,
tokenString,
)
if err != nil {
return nil, fmt.Errorf("cannot validate trust center access token: %w", err)
}
access := &coredata.TrustCenterAccess{}
err = s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, token.Data.TrustCenterID, token.Data.Email)
})
if err != nil {
return nil, fmt.Errorf("access not found or revoked: %w", err)
}
if !access.Active {
return nil, fmt.Errorf("access has been revoked")
}
return &token.Data, nil
}
func (s TrustCenterAccessService) IsAccessActive(
ctx context.Context,
trustCenterID gid.GID,
email string,
) (bool, error) {
access := &coredata.TrustCenterAccess{}
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
})
if err != nil {
return false, fmt.Errorf("cannot load trust center access: %w", err)
}
return access.Active, nil
}
func (s TrustCenterAccessService) Create(
ctx context.Context,
req *CreateTrustCenterAccessRequest,
) (*coredata.TrustCenterAccess, error) {
if !strings.Contains(req.Email, "@") {
return nil, fmt.Errorf("invalid email address")
}
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
now := time.Now()
existingAccess := &coredata.TrustCenterAccess{}
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return existingAccess.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, req.TrustCenterID, req.Email)
})
var access *coredata.TrustCenterAccess
if err == nil {
access = existingAccess
access.Name = req.Name
access.Active = true
access.UpdatedAt = now
err = s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := access.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update trust center access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
} else {
access = &coredata.TrustCenterAccess{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.TrustCenterAccessEntityType),
TenantID: s.svc.scope.GetTenantID(),
TrustCenterID: req.TrustCenterID,
Email: req.Email,
Name: req.Name,
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
err = s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := access.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert trust center access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
}
if req.SendEmail {
if err := s.sendAccessEmail(ctx, access); err != nil {
fmt.Printf("Failed to send access email\n")
}
}
return access, nil
}
func (s TrustCenterAccessService) Update(
ctx context.Context,
req *UpdateTrustCenterAccessRequest,
) (*coredata.TrustCenterAccess, error) {
access := &coredata.TrustCenterAccess{}
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := access.LoadByID(ctx, tx, s.svc.scope, req.AccessID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
if req.Email != nil {
if !strings.Contains(*req.Email, "@") {
return fmt.Errorf("invalid email address")
}
access.Email = *req.Email
}
if req.Name != nil {
if *req.Name == "" {
return fmt.Errorf("name cannot be empty")
}
access.Name = *req.Name
}
if req.Active != nil {
access.Active = *req.Active
}
access.UpdatedAt = time.Now()
if err := access.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update trust center access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
if req.SendEmail && access.Active {
if err := s.sendAccessEmail(ctx, access); err != nil {
fmt.Printf("Failed to send access email\n")
}
}
return access, nil
}
func (s TrustCenterAccessService) Delete(
ctx context.Context,
req *DeleteTrustCenterAccessRequest,
) error {
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
access := &coredata.TrustCenterAccess{}
if err := access.LoadByID(ctx, tx, s.svc.scope, req.AccessID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
if err := access.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete trust center access: %w", err)
}
return nil
})
return err
}
func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, access *coredata.TrustCenterAccess) error {
accessToken, err := statelesstoken.NewToken(
s.svc.tokenSecret,
TokenTypeTrustCenterAccess,
7*24*time.Hour,
TrustCenterAccessData{
TrustCenterID: access.TrustCenterID,
Email: access.Email,
},
)
if err != nil {
return fmt.Errorf("cannot generate access token: %w", err)
}
trustCenter := &coredata.TrustCenter{}
err = s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return trustCenter.LoadByID(ctx, conn, s.svc.scope, access.TrustCenterID)
})
if err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
organization := &coredata.Organization{}
err = s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return organization.LoadByID(ctx, conn, s.svc.scope, trustCenter.OrganizationID)
})
if err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
accessURL := url.URL{
Scheme: "https",
Host: s.svc.hostname,
Path: "/trust/" + trustCenter.Slug + "/access",
RawQuery: "token=" + url.QueryEscape(accessToken),
}
return s.usrmgr.SendTrustCenterAccessEmail(ctx, access.Name, access.Email, organization.Name, accessURL.String())
}

View File

@@ -86,7 +86,7 @@ func (s TrustCenterService) GetByOrganizationID(
return trustCenter, nil
}
func (s *TrustCenterService) Update(
func (s TrustCenterService) Update(
ctx context.Context,
req *UpdateTrustCenterRequest,
) (*coredata.TrustCenter, error) {

View File

@@ -131,12 +131,14 @@ func (s VendorService) ListForOrganizationID(
return fmt.Errorf("cannot load organization: %w", err)
}
filter := coredata.NewVendorFilter()
return vendors.LoadByOrganizationID(
ctx,
conn,
s.svc.scope,
organization.ID,
cursor,
filter,
)
},
)

View File

@@ -35,7 +35,8 @@ import (
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/saferedirect"
"github.com/getprobo/probo/pkg/server"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/server/api"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
@@ -226,22 +227,34 @@ func (impl *Implm) Run(
impl.cfg.Auth.Cookie.Secret,
agentConfig,
html2pdfConverter,
usrmgrService,
)
if err != nil {
return fmt.Errorf("cannot create probo service: %w", err)
}
trustService := trust.NewService(
pgClient,
s3Client,
impl.cfg.AWS.Bucket,
impl.cfg.EncryptionKey,
impl.cfg.Auth.Cookie.Secret,
usrmgrService,
html2pdfConverter,
)
serverHandler, err := server.NewServer(
server.Config{
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields,
Probo: proboService,
Usrmgr: usrmgrService,
Trust: trustService,
ConnectorRegistry: defaultConnectorRegistry,
Agent: agent,
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
Logger: l.Named("http.server"),
Auth: console_v1.AuthConfig{
Auth: api.AuthConfig{
CookieName: impl.cfg.Auth.Cookie.Name,
CookieDomain: impl.cfg.Auth.Cookie.Domain,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,

View File

@@ -18,10 +18,14 @@ import (
"errors"
"net/http"
"time"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/saferedirect"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
@@ -30,11 +34,19 @@ import (
)
type (
AuthConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
}
Config struct {
AllowedOrigins []string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth console_v1.AuthConfig
Trust *trust.Service
Auth AuthConfig
ConnectorRegistry *connector.ConnectorRegistry
SafeRedirect *saferedirect.SafeRedirect
Logger *log.Logger
@@ -122,11 +134,32 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.cfg.Logger.Named("console.v1"),
s.cfg.Probo,
s.cfg.Usrmgr,
s.cfg.Auth,
console_v1.AuthConfig{
CookieName: s.cfg.Auth.CookieName,
CookieDomain: s.cfg.Auth.CookieDomain,
SessionDuration: s.cfg.Auth.SessionDuration,
CookieSecret: s.cfg.Auth.CookieSecret,
},
s.cfg.ConnectorRegistry,
s.cfg.SafeRedirect,
),
)
// Mount the trust API with authentication
router.Mount(
"/trust/v1",
trust_v1.NewMux(
s.cfg.Logger.Named("trust.v1"),
s.cfg.Usrmgr,
s.cfg.Trust,
trust_v1.AuthConfig{
CookieName: s.cfg.Auth.CookieName,
CookieDomain: s.cfg.Auth.CookieDomain,
SessionDuration: s.cfg.Auth.SessionDuration,
CookieSecret: s.cfg.Auth.CookieSecret,
},
),
)
router.ServeHTTP(w, r)
}

View File

@@ -566,6 +566,14 @@ enum AuditOrderField
)
}
enum TrustCenterAccessOrderField
@goModel(model: "github.com/getprobo/probo/pkg/coredata.TrustCenterAccessOrderField") {
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.TrustCenterAccessOrderFieldCreatedAt"
)
}
# Input Types
input UserOrder
@goModel(
@@ -647,6 +655,14 @@ input AuditOrder
field: AuditOrderField!
}
input TrustCenterAccessOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.TrustCenterAccessOrderBy"
) {
direction: OrderDirection!
field: TrustCenterAccessOrderField!
}
input EvidenceOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.EvidenceOrderBy"
@@ -702,6 +718,14 @@ input RiskFilter {
query: String
}
input OrganizationFilter {
trustCenterSlug: String
}
input TrustCenterFilter {
slug: String
}
# Core Types
type TrustCenter implements Node {
id: ID!
@@ -709,6 +733,15 @@ type TrustCenter implements Node {
slug: String!
createdAt: Datetime!
updatedAt: Datetime!
organization: Organization! @goField(forceResolver: true)
accesses(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: TrustCenterAccessOrder
): TrustCenterAccessConnection! @goField(forceResolver: true)
}
type Organization implements Node {
@@ -1177,6 +1210,7 @@ type Viewer {
last: Int
before: CursorKey
orderBy: OrganizationOrder
filter: OrganizationFilter
): OrganizationConnection! @goField(forceResolver: true)
}
@@ -1191,6 +1225,35 @@ type OrganizationEdge {
node: Organization!
}
type TrustCenterConnection {
edges: [TrustCenterEdge!]!
pageInfo: PageInfo!
}
type TrustCenterEdge {
cursor: CursorKey!
node: TrustCenter!
}
type TrustCenterAccess implements Node {
id: ID!
email: String!
name: String!
active: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
}
type TrustCenterAccessConnection {
edges: [TrustCenterAccessEdge!]!
pageInfo: PageInfo!
}
type TrustCenterAccessEdge {
cursor: CursorKey!
node: TrustCenterAccess!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
@@ -1399,6 +1462,13 @@ type AuditEdge {
type Query {
node(id: ID!): Node!
viewer: Viewer!
trustCenters(
first: Int
after: CursorKey
last: Int
before: CursorKey
filter: TrustCenterFilter
): TrustCenterConnection! @goField(forceResolver: true)
}
type Mutation {
@@ -1414,6 +1484,23 @@ type Mutation {
input: UpdateTrustCenterInput!
): UpdateTrustCenterPayload!
revokeTrustCenterAccess(
input: RevokeTrustCenterAccessInput!
): RevokeTrustCenterAccessPayload!
# Trust Center Access CRUD mutations
createTrustCenterAccess(
input: CreateTrustCenterAccessInput!
): CreateTrustCenterAccessPayload!
updateTrustCenterAccess(
input: UpdateTrustCenterAccessInput!
): UpdateTrustCenterAccessPayload!
deleteTrustCenterAccess(
input: DeleteTrustCenterAccessInput!
): DeleteTrustCenterAccessPayload!
# User mutations
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
@@ -1586,6 +1673,29 @@ input UpdateTrustCenterInput {
slug: String
}
input RevokeTrustCenterAccessInput {
accessId: ID!
}
input CreateTrustCenterAccessInput {
trustCenterId: ID!
email: String!
name: String!
sendEmail: Boolean! = true
}
input UpdateTrustCenterAccessInput {
accessId: ID!
email: String
name: String
active: Boolean
sendEmail: Boolean! = false
}
input DeleteTrustCenterAccessInput {
accessId: ID!
}
input CreateVendorInput {
organizationId: ID!
name: String!
@@ -1951,6 +2061,24 @@ type UpdateTrustCenterPayload {
trustCenter: TrustCenter!
}
type RevokeTrustCenterAccessPayload {
trustCenterAccess: TrustCenterAccess!
}
type CreateTrustCenterAccessPayload {
trustCenterAccessEdge: TrustCenterAccessEdge!
}
type UpdateTrustCenterAccessPayload {
trustCenterAccess: TrustCenterAccess!
}
type DeleteTrustCenterAccessPayload {
deletedTrustCenterAccessId: ID!
}
type CreateControlPayload {
controlEdge: ControlEdge!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
type TrustCenterAccessOrderBy = OrderBy[coredata.TrustCenterAccessOrderField]
func NewTrustCenterAccess(tca *coredata.TrustCenterAccess) *TrustCenterAccess {
return &TrustCenterAccess{
ID: tca.ID,
Email: tca.Email,
Name: tca.Name,
Active: tca.Active,
CreatedAt: tca.CreatedAt,
UpdatedAt: tca.UpdatedAt,
}
}
func NewTrustCenterAccessConnection(
page *page.Page[*coredata.TrustCenterAccess, coredata.TrustCenterAccessOrderField],
) *TrustCenterAccessConnection {
var edges = make([]*TrustCenterAccessEdge, len(page.Data))
for i := range edges {
edges[i] = NewTrustCenterAccessEdge(page.Data[i], page.Cursor.OrderBy.Field)
}
return &TrustCenterAccessConnection{
Edges: edges,
PageInfo: NewPageInfo(page),
}
}
func NewTrustCenterAccessEdge(tca *coredata.TrustCenterAccess, orderBy coredata.TrustCenterAccessOrderField) *TrustCenterAccessEdge {
return &TrustCenterAccessEdge{
Cursor: tca.CursorKey(orderBy),
Node: NewTrustCenterAccess(tca),
}
}
// Types are auto-generated in types.go - only helper functions remain here

View File

@@ -367,6 +367,17 @@ type CreateTaskPayload struct {
TaskEdge *TaskEdge `json:"taskEdge"`
}
type CreateTrustCenterAccessInput struct {
TrustCenterID gid.GID `json:"trustCenterId"`
Email string `json:"email"`
Name string `json:"name"`
SendEmail bool `json:"sendEmail"`
}
type CreateTrustCenterAccessPayload struct {
TrustCenterAccessEdge *TrustCenterAccessEdge `json:"trustCenterAccessEdge"`
}
type CreateVendorInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
@@ -561,6 +572,14 @@ type DeleteTaskPayload struct {
DeletedTaskID gid.GID `json:"deletedTaskId"`
}
type DeleteTrustCenterAccessInput struct {
AccessID gid.GID `json:"accessId"`
}
type DeleteTrustCenterAccessPayload struct {
DeletedTrustCenterAccessID gid.GID `json:"deletedTrustCenterAccessId"`
}
type DeleteVendorComplianceReportInput struct {
ReportID gid.GID `json:"reportId"`
}
@@ -836,6 +855,10 @@ type OrganizationEdge struct {
Node *Organization `json:"node"`
}
type OrganizationFilter struct {
TrustCenterSlug *string `json:"trustCenterSlug,omitempty"`
}
type OrganizationOrder struct {
Direction page.OrderDirection `json:"direction"`
Field coredata.OrganizationOrderField `json:"field"`
@@ -925,6 +948,14 @@ type RequestSignaturePayload struct {
DocumentVersionSignatureEdge *DocumentVersionSignatureEdge `json:"documentVersionSignatureEdge"`
}
type RevokeTrustCenterAccessInput struct {
AccessID gid.GID `json:"accessId"`
}
type RevokeTrustCenterAccessPayload struct {
TrustCenterAccess *TrustCenterAccess `json:"trustCenterAccess"`
}
type Risk struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
@@ -996,16 +1027,54 @@ type TaskEdge struct {
}
type TrustCenter struct {
ID gid.GID `json:"id"`
Active bool `json:"active"`
Slug string `json:"slug"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID gid.GID `json:"id"`
Active bool `json:"active"`
Slug string `json:"slug"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Organization *Organization `json:"organization"`
Accesses *TrustCenterAccessConnection `json:"accesses"`
}
func (TrustCenter) IsNode() {}
func (this TrustCenter) GetID() gid.GID { return this.ID }
type TrustCenterAccess struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (TrustCenterAccess) IsNode() {}
func (this TrustCenterAccess) GetID() gid.GID { return this.ID }
type TrustCenterAccessConnection struct {
Edges []*TrustCenterAccessEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type TrustCenterAccessEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *TrustCenterAccess `json:"node"`
}
type TrustCenterConnection struct {
Edges []*TrustCenterEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type TrustCenterEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *TrustCenter `json:"node"`
}
type TrustCenterFilter struct {
Slug *string `json:"slug,omitempty"`
}
type UnassignTaskInput struct {
TaskID gid.GID `json:"taskId"`
}
@@ -1167,6 +1236,18 @@ type UpdateTaskPayload struct {
Task *Task `json:"task"`
}
type UpdateTrustCenterAccessInput struct {
AccessID gid.GID `json:"accessId"`
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
Active *bool `json:"active,omitempty"`
SendEmail bool `json:"sendEmail"`
}
type UpdateTrustCenterAccessPayload struct {
TrustCenterAccess *TrustCenterAccess `json:"trustCenterAccess"`
}
type UpdateTrustCenterInput struct {
TrustCenterID gid.GID `json:"trustCenterId"`
Active *bool `json:"active,omitempty"`

View File

@@ -998,6 +998,77 @@ func (r *mutationResolver) UpdateTrustCenter(ctx context.Context, input types.Up
}, nil
}
// RevokeTrustCenterAccess is the resolver for the revokeTrustCenterAccess field.
func (r *mutationResolver) RevokeTrustCenterAccess(ctx context.Context, input types.RevokeTrustCenterAccessInput) (*types.RevokeTrustCenterAccessPayload, error) {
prb := r.ProboService(ctx, input.AccessID.TenantID())
access, err := prb.TrustCenterAccesses.RevokeAccess(ctx, &probo.RevokeTrustCenterAccessRequest{
AccessID: input.AccessID,
})
if err != nil {
return nil, fmt.Errorf("cannot revoke trust center access: %w", err)
}
return &types.RevokeTrustCenterAccessPayload{
TrustCenterAccess: types.NewTrustCenterAccess(access),
}, nil
}
// CreateTrustCenterAccess is the resolver for the createTrustCenterAccess field.
func (r *mutationResolver) CreateTrustCenterAccess(ctx context.Context, input types.CreateTrustCenterAccessInput) (*types.CreateTrustCenterAccessPayload, error) {
prb := r.ProboService(ctx, input.TrustCenterID.TenantID())
access, err := prb.TrustCenterAccesses.Create(ctx, &probo.CreateTrustCenterAccessRequest{
TrustCenterID: input.TrustCenterID,
Email: input.Email,
Name: input.Name,
SendEmail: input.SendEmail,
})
if err != nil {
return nil, fmt.Errorf("cannot create trust center access: %w", err)
}
return &types.CreateTrustCenterAccessPayload{
TrustCenterAccessEdge: types.NewTrustCenterAccessEdge(access, coredata.TrustCenterAccessOrderFieldCreatedAt),
}, nil
}
// UpdateTrustCenterAccess is the resolver for the updateTrustCenterAccess field.
func (r *mutationResolver) UpdateTrustCenterAccess(ctx context.Context, input types.UpdateTrustCenterAccessInput) (*types.UpdateTrustCenterAccessPayload, error) {
prb := r.ProboService(ctx, input.AccessID.TenantID())
access, err := prb.TrustCenterAccesses.Update(ctx, &probo.UpdateTrustCenterAccessRequest{
AccessID: input.AccessID,
Email: input.Email,
Name: input.Name,
Active: input.Active,
SendEmail: input.SendEmail,
})
if err != nil {
return nil, fmt.Errorf("cannot update trust center access: %w", err)
}
return &types.UpdateTrustCenterAccessPayload{
TrustCenterAccess: types.NewTrustCenterAccess(access),
}, nil
}
// DeleteTrustCenterAccess is the resolver for the deleteTrustCenterAccess field.
func (r *mutationResolver) DeleteTrustCenterAccess(ctx context.Context, input types.DeleteTrustCenterAccessInput) (*types.DeleteTrustCenterAccessPayload, error) {
prb := r.ProboService(ctx, input.AccessID.TenantID())
err := prb.TrustCenterAccesses.Delete(ctx, &probo.DeleteTrustCenterAccessRequest{
AccessID: input.AccessID,
})
if err != nil {
return nil, fmt.Errorf("cannot delete trust center access: %w", err)
}
return &types.DeleteTrustCenterAccessPayload{
DeletedTrustCenterAccessID: input.AccessID,
}, nil
}
// ConfirmEmail is the resolver for the confirmEmail field.
func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error) {
err := r.usrmgrSvc.ConfirmEmail(ctx, input.Token)
@@ -2883,7 +2954,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewReport(report), nil
case coredata.TrustCenterEntityType:
trustCenter, err := prb.TrustCenters.GetByOrganizationID(ctx, id)
trustCenter, err := prb.TrustCenters.Get(ctx, id)
if err != nil {
panic(fmt.Errorf("cannot get trust center: %w", err))
}
@@ -2905,6 +2976,11 @@ func (r *queryResolver) Viewer(ctx context.Context) (*types.Viewer, error) {
}, nil
}
// TrustCenters is the resolver for the trustCenters field.
func (r *queryResolver) TrustCenters(ctx context.Context, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterFilter) (*types.TrustCenterConnection, error) {
panic(fmt.Errorf("not implemented: TrustCenters - trustCenters"))
}
// DownloadURL is the resolver for the downloadUrl field.
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -3167,6 +3243,43 @@ func (r *taskConnectionResolver) TotalCount(ctx context.Context, obj *types.Task
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Organization is the resolver for the organization field.
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
organization, err := prb.Organizations.Get(ctx, obj.Organization.ID)
if err != nil {
return nil, fmt.Errorf("cannot get organization: %w", err)
}
return types.NewOrganization(organization), nil
}
// Accesses is the resolver for the accesses field.
func (r *trustCenterResolver) Accesses(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrderBy[coredata.TrustCenterAccessOrderField]) (*types.TrustCenterAccessConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.TrustCenterAccessOrderField]{
Field: coredata.TrustCenterAccessOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.TrustCenterAccessOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
result, err := prb.TrustCenterAccesses.ListForTrustCenterID(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list trust center accesses: %w", err))
}
return types.NewTrustCenterAccessConnection(result), nil
}
// People is the resolver for the people field.
func (r *userResolver) People(ctx context.Context, obj *types.User, organizationID gid.GID) (*types.People, error) {
prb := r.ProboService(ctx, organizationID.TenantID())
@@ -3369,7 +3482,7 @@ func (r *vendorRiskAssessmentResolver) AssessedBy(ctx context.Context, obj *type
}
// Organizations is the resolver for the organizations field.
func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrganizationOrder) (*types.OrganizationConnection, error) {
func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrganizationOrder, filter *types.OrganizationFilter) (*types.OrganizationConnection, error) {
user := UserFromContext(ctx)
// For now, we're not using cursor pagination since we're loading all organizations
@@ -3496,6 +3609,9 @@ func (r *Resolver) Task() schema.TaskResolver { return &taskResolver{r} }
// TaskConnection returns schema.TaskConnectionResolver implementation.
func (r *Resolver) TaskConnection() schema.TaskConnectionResolver { return &taskConnectionResolver{r} }
// TrustCenter returns schema.TrustCenterResolver implementation.
func (r *Resolver) TrustCenter() schema.TrustCenterResolver { return &trustCenterResolver{r} }
// User returns schema.UserResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
@@ -3547,6 +3663,7 @@ type riskResolver struct{ *Resolver }
type riskConnectionResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }
type taskConnectionResolver struct{ *Resolver }
type trustCenterResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type vendorResolver struct{ *Resolver }
type vendorComplianceReportResolver struct{ *Resolver }

View File

@@ -0,0 +1,29 @@
schema: ["schema.graphql"]
exec:
filename: "schema/schema.go"
package: "schema"
model:
filename: "types/types.go"
package: "types"
resolver:
layout: "follow-schema"
dir: "."
package: "trust_v1"
filename_template: "v1_resolver.go"
autobind: []
call_argument_directives_with_null: true
models:
ID:
model:
- "github.com/getprobo/probo/pkg/server/api/trust/v1/types.GIDScalar"
Datetime:
model:
- "github.com/99designs/gqlgen/graphql.Time"
CursorKey:
model:
- "github.com/getprobo/probo/pkg/server/api/trust/v1/types.CursorKeyScalar"

View File

@@ -0,0 +1,277 @@
//go:generate go run github.com/99designs/gqlgen generate
// 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 trust_v1
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/server/api/trust/v1/schema"
"github.com/getprobo/probo/pkg/server/api/trust/v1/types"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
type (
AuthConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
}
Resolver struct {
trustCenterSvc *trust.Service
authCfg AuthConfig
}
ctxKey struct{ name string }
TokenAccessData struct {
TrustCenterID gid.GID
Email string
TenantID gid.TenantID
Scope string
}
TrustCenterTokenData struct {
TrustCenterID gid.GID `json:"trust_center_id"`
Email string `json:"email"`
TenantID gid.TenantID `json:"tenant_id"`
Scope string `json:"scope"`
ExpiresAt time.Time `json:"expires_at"`
}
)
const (
TokenScopeTrustCenterReadOnly = "trust_center_readonly"
TokenCookieName = "trust_center_token"
)
var (
sessionContextKey = &ctxKey{name: "session"}
userContextKey = &ctxKey{name: "user"}
userTenantContextKey = &ctxKey{name: "user_tenants"}
tokenAccessContextKey = &ctxKey{name: "token_access"}
)
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
}
func TokenAccessFromContext(ctx context.Context) *TokenAccessData {
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*TokenAccessData)
return tokenAccess
}
func GetCurrentUserRole(ctx context.Context) types.Role {
user := UserFromContext(ctx)
tokenAccess := TokenAccessFromContext(ctx)
if user != nil || tokenAccess != nil {
return types.RoleUser
}
return types.RoleNone
}
func NewMux(
logger *log.Logger,
usrmgrSvc *usrmgr.Service,
trustSvc *trust.Service,
authCfg AuthConfig,
) *chi.Mux {
r := chi.NewMux()
encryptionKey := trustSvc.GetEncryptionKey()
r.Handle("/graphql", graphqlHandler(logger, usrmgrSvc, trustSvc, authCfg, encryptionKey))
r.Handle("/playground", playground.Handler("GraphQL Playground", "/api/trust/v1/graphql"))
r.Post("/trust-center-access/authenticate", authTokenHandler(trustSvc, authCfg, encryptionKey))
r.Delete("/trust-center-access/logout", trustCenterLogoutHandler(authCfg))
return r
}
func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg AuthConfig, encryptionKey cipher.EncryptionKey) http.HandlerFunc {
var mb int64 = 1 << 20
c := schema.Config{
Resolvers: &Resolver{
trustCenterSvc: trustSvc,
authCfg: authCfg,
},
}
c.Directives.MustBeAuthenticated = func(ctx context.Context, obj interface{}, next graphql.Resolver, role *types.Role) (interface{}, error) {
currentRole := GetCurrentUserRole(ctx)
if role != nil && *role == types.RoleUser && currentRole == types.RoleNone {
return nil, fmt.Errorf("access denied: authentication required")
}
return next(ctx)
}
es := schema.NewExecutableSchema(c)
srv := handler.New(es)
srv.AddTransport(transport.POST{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.Options{})
srv.AddTransport(
transport.MultipartForm{
MaxMemory: 32 * mb,
MaxUploadSize: 50 * mb,
},
)
srv.Use(extension.Introspection{})
srv.SetRecoverFunc(func(ctx context.Context, err any) error {
logger := httpserver.LoggerFromContext(ctx)
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))
return errors.New("internal server error")
})
return WithSession(usrmgrSvc, trustSvc, authCfg, encryptionKey, srv.ServeHTTP)
}
// TrustService returns a trust service scoped to the given tenant
func (r *Resolver) TrustService(ctx context.Context, tenantID gid.TenantID) *trust.TenantService {
return r.trustCenterSvc.WithTenant(tenantID)
}
// GetTenantService returns a tenant service for the given tenant ID
func (r *Resolver) GetTenantService(ctx context.Context, tenantID gid.TenantID) *trust.TenantService {
return r.trustCenterSvc.WithTenant(tenantID)
}
func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg AuthConfig, encryptionKey cipher.EncryptionKey, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cookieValue, err := securecookie.Get(r, securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
))
if err == nil {
sessionID, err := gid.ParseGID(cookieValue)
if err == nil {
session, err := usrmgrSvc.GetSession(ctx, sessionID)
if err == nil {
user, err := usrmgrSvc.GetUserBySession(ctx, sessionID)
if err == nil {
tenantIDs, err := usrmgrSvc.ListTenantsForUserID(ctx, user.ID)
if err == nil {
ctx = context.WithValue(ctx, sessionContextKey, session)
ctx = context.WithValue(ctx, userContextKey, user)
ctx = context.WithValue(ctx, userTenantContextKey, &tenantIDs)
next(w, r.WithContext(ctx))
if err := usrmgrSvc.UpdateSession(ctx, session); err != nil {
panic(fmt.Errorf("failed to update session: %w", err))
}
return
}
}
}
}
securecookie.Clear(w, securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
))
}
tokenCookieValue, err := securecookie.Get(r, securecookie.Config{
Name: TokenCookieName,
Secret: authCfg.CookieSecret,
})
if err == nil {
encryptedData, err := base64.StdEncoding.DecodeString(tokenCookieValue)
if err == nil {
decryptedData, err := cipher.Decrypt(encryptedData, encryptionKey)
if err == nil {
var tokenData TrustCenterTokenData
if err := json.Unmarshal(decryptedData, &tokenData); err == nil {
if time.Now().Before(tokenData.ExpiresAt) {
tenantSvc := trustSvc.WithTenant(tokenData.TenantID)
isActive, err := tenantSvc.TrustCenterAccesses.IsAccessActive(ctx, tokenData.TrustCenterID, tokenData.Email)
if err == nil && isActive {
tokenAccess := &TokenAccessData{
TrustCenterID: tokenData.TrustCenterID,
Email: tokenData.Email,
TenantID: tokenData.TenantID,
Scope: tokenData.Scope,
}
ctx = context.WithValue(ctx, tokenAccessContextKey, tokenAccess)
next(w, r.WithContext(ctx))
return
} else {
securecookie.Clear(w, securecookie.Config{
Name: TokenCookieName,
Secret: authCfg.CookieSecret,
})
}
} else {
securecookie.Clear(w, securecookie.Config{
Name: TokenCookieName,
Secret: authCfg.CookieSecret,
})
}
}
}
}
}
// Continue without authentication for public access
next(w, r.WithContext(ctx))
}
}

View File

@@ -0,0 +1,263 @@
# Directives
directive @goField(
forceResolver: Boolean
name: String
omittable: Boolean
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
directive @goModel(
model: String
models: [String!]
) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION
directive @goEnum(value: String) on ENUM_VALUE
directive @mustBeAuthenticated(role: Role = NONE) on FIELD_DEFINITION | OBJECT
enum Role {
NONE
USER
}
scalar Datetime
scalar CursorKey
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
type Organization implements Node {
id: ID!
name: String!
logoUrl: String @goField(forceResolver: true)
}
enum DocumentType
@goModel(model: "github.com/getprobo/probo/pkg/coredata.DocumentType") {
OTHER
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.DocumentTypeOther")
ISMS @goEnum(value: "github.com/getprobo/probo/pkg/coredata.DocumentTypeISMS")
POLICY
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.DocumentTypePolicy")
}
type DocumentVersion implements Node {
id: ID!
}
type Document implements Node {
id: ID!
title: String!
documentType: DocumentType!
versions(
first: Int
after: CursorKey
last: Int
before: CursorKey
): DocumentVersionConnection! @goField(forceResolver: true)
}
type DocumentConnection {
edges: [DocumentEdge!]!
pageInfo: PageInfo!
}
type DocumentEdge {
cursor: CursorKey!
node: Document!
}
type DocumentVersionConnection {
edges: [DocumentVersionEdge!]!
pageInfo: PageInfo!
}
type DocumentVersionEdge {
cursor: CursorKey!
node: DocumentVersion!
}
type Framework implements Node {
id: ID!
name: String!
}
type Report implements Node {
id: ID!
filename: String!
downloadUrl: String @goField(forceResolver: true) @mustBeAuthenticated(role: USER)
}
type Audit implements Node {
id: ID!
framework: Framework! @goField(forceResolver: true)
report: Report @goField(forceResolver: true)
reportUrl: String @goField(forceResolver: true) @mustBeAuthenticated(role: USER)
}
type AuditConnection {
edges: [AuditEdge!]!
pageInfo: PageInfo!
}
type AuditEdge {
cursor: CursorKey!
node: Audit!
}
enum VendorCategory
@goModel(model: "github.com/getprobo/probo/pkg/coredata.VendorCategory") {
ANALYTICS
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryAnalytics"
)
CLOUD_MONITORING
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryCloudMonitoring"
)
CLOUD_PROVIDER
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryCloudProvider"
)
COLLABORATION
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryCollaboration"
)
CUSTOMER_SUPPORT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryCustomerSupport"
)
DATA_STORAGE_AND_PROCESSING
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryDataStorageAndProcessing"
)
DOCUMENT_MANAGEMENT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryDocumentManagement"
)
EMPLOYEE_MANAGEMENT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryEmployeeManagement"
)
ENGINEERING
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryEngineering"
)
FINANCE
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryFinance"
)
IDENTITY_PROVIDER
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryIdentityProvider"
)
IT @goEnum(value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryIT")
MARKETING
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryMarketing"
)
OFFICE_OPERATIONS
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryOfficeOperations"
)
OTHER
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryOther")
PASSWORD_MANAGEMENT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryPasswordManagement"
)
PRODUCT_AND_DESIGN
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryProductAndDesign"
)
PROFESSIONAL_SERVICES
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryProfessionalServices"
)
RECRUITING
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryRecruiting"
)
SALES
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.VendorCategorySales")
SECURITY
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategorySecurity"
)
VERSION_CONTROL
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.VendorCategoryVersionControl"
)
}
type Vendor implements Node {
id: ID!
name: String!
category: VendorCategory!
websiteUrl: String
privacyPolicyUrl: String
}
type VendorConnection {
edges: [VendorEdge!]!
pageInfo: PageInfo!
}
type VendorEdge {
cursor: CursorKey!
node: Vendor!
}
type TrustCenter implements Node {
id: ID!
active: Boolean!
slug: String!
organization: Organization! @goField(forceResolver: true)
documents(
first: Int
after: CursorKey
last: Int
before: CursorKey
): DocumentConnection! @goField(forceResolver: true)
audits(
first: Int
after: CursorKey
last: Int
before: CursorKey
): AuditConnection! @goField(forceResolver: true)
vendors(
first: Int
after: CursorKey
last: Int
before: CursorKey
): VendorConnection! @goField(forceResolver: true)
}
input ExportDocumentVersionPDFInput {
documentVersionId: ID!
}
type ExportDocumentVersionPDFPayload {
data: String!
}
type Query {
trustCenterBySlug(slug: String!): TrustCenter @mustBeAuthenticated(role: NONE)
}
type Mutation {
exportDocumentVersionPDF(
input: ExportDocumentVersionPDFInput!
): ExportDocumentVersionPDFPayload! @mustBeAuthenticated(role: USER)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
// 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 trust_v1
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/getprobo/probo/pkg/crypto/cipher"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/trust"
"go.gearno.de/kit/httpserver"
)
type (
AuthTokenRequest struct {
Token string `json:"token"`
}
AuthTokenResponse struct {
Success bool `json:"success"`
TrustCenterID string `json:"trust_center_id,omitempty"`
Message string `json:"message,omitempty"`
}
)
func authTokenHandler(trustSvc *trust.Service, authCfg AuthConfig, encryptionKey cipher.EncryptionKey) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req AuthTokenRequest
// Limit request body size to 1KB to prevent DoS attacks
limitedReader := http.MaxBytesReader(w, r.Body, 1024)
if err := json.NewDecoder(limitedReader).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
if req.Token == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, AuthTokenResponse{
Success: false,
Message: "Token is required",
})
return
}
accessData, err := validateTrustCenterAccessToken(r.Context(), trustSvc, authCfg, req.Token)
if err != nil {
httpserver.RenderJSON(w, http.StatusUnauthorized, AuthTokenResponse{
Success: false,
Message: "Invalid or expired token",
})
return
}
tokenData := TrustCenterTokenData{
TrustCenterID: accessData.TrustCenterID,
Email: accessData.Email,
TenantID: accessData.TrustCenterID.TenantID(),
Scope: TokenScopeTrustCenterReadOnly,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
tokenBytes, err := json.Marshal(tokenData)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to serialize token data: %w", err))
return
}
encryptedTokenData, err := cipher.Encrypt(tokenBytes, encryptionKey)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to encrypt token data: %w", err))
return
}
encryptedTokenString := base64.StdEncoding.EncodeToString(encryptedTokenData)
cookieConfig := securecookie.Config{
Name: TokenCookieName,
Secret: authCfg.CookieSecret,
Domain: authCfg.CookieDomain,
Path: "/",
MaxAge: int(24 * time.Hour / time.Second), // 24 hours
Secure: true,
HTTPOnly: true,
SameSite: http.SameSiteStrictMode,
}
if err := securecookie.Set(w, cookieConfig, encryptedTokenString); err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to set cookie: %w", err))
return
}
httpserver.RenderJSON(w, http.StatusOK, AuthTokenResponse{
Success: true,
TrustCenterID: accessData.TrustCenterID.String(),
Message: "Authentication successful",
})
}
}
func validateTrustCenterAccessToken(ctx context.Context, trustSvc *trust.Service, authCfg AuthConfig, tokenString string) (*probo.TrustCenterAccessData, error) {
token, err := statelesstoken.ValidateToken[probo.TrustCenterAccessData](
authCfg.CookieSecret,
probo.TokenTypeTrustCenterAccess,
tokenString,
)
if err != nil {
return nil, fmt.Errorf("cannot validate trust center access token: %w", err)
}
tenantID := token.Data.TrustCenterID.TenantID()
tenantSvc := trustSvc.WithTenant(tenantID)
return tenantSvc.TrustCenterAccesses.ValidateToken(ctx, tokenString)
}
func trustCenterLogoutHandler(authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookieConfig := securecookie.Config{
Name: TokenCookieName,
Secret: authCfg.CookieSecret,
Domain: authCfg.CookieDomain,
Path: "/",
MaxAge: -1,
Secure: true,
HTTPOnly: true,
SameSite: http.SameSiteStrictMode,
}
securecookie.Clear(w, cookieConfig)
w.Header().Set("Clear-Site-Data", "*")
httpserver.RenderJSON(w, http.StatusOK, map[string]bool{"success": true})
}
}

View File

@@ -0,0 +1,47 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
func NewAuditConnection(
p *page.Page[*coredata.Audit, coredata.AuditOrderField],
) *AuditConnection {
edges := make([]*AuditEdge, len(p.Data))
for i, audit := range p.Data {
edges[i] = NewAuditEdge(audit, p.Cursor.OrderBy.Field)
}
return &AuditConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewAudit(a *coredata.Audit) *Audit {
return &Audit{
ID: a.ID,
}
}
func NewAuditEdge(a *coredata.Audit, orderField coredata.AuditOrderField) *AuditEdge {
return &AuditEdge{
Node: NewAudit(a),
Cursor: a.CursorKey(orderField),
}
}

View File

@@ -0,0 +1,70 @@
// 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 types
import (
"errors"
"io"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/getprobo/probo/pkg/page"
)
func NewCursor[O page.OrderField](
first *int,
after *page.CursorKey,
last *int,
before *page.CursorKey,
orderBy page.OrderBy[O],
) *page.Cursor[O] {
var (
size int
from *page.CursorKey
direction = page.Head
)
if first != nil {
size = *first
direction = page.Head
from = after
} else if last != nil {
size = *last
direction = page.Tail
from = before
}
return page.NewCursor(size, from, direction, orderBy)
}
func MarshalCursorKeyScalar(ck page.CursorKey) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
_, _ = w.Write([]byte(strconv.Quote(ck.String())))
})
}
func UnmarshalCursorKeyScalar(v interface{}) (page.CursorKey, error) {
s, ok := v.(string)
if !ok {
return page.CursorKeyNil, errors.New("must be a string")
}
ck, err := page.ParseCursorKey(s)
if err != nil {
return page.CursorKeyNil, err
}
return ck, nil
}

View File

@@ -0,0 +1,49 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
func NewDocumentConnection(
p *page.Page[*coredata.Document, coredata.DocumentOrderField],
) *DocumentConnection {
edges := make([]*DocumentEdge, len(p.Data))
for i, document := range p.Data {
edges[i] = NewDocumentEdge(document, p.Cursor.OrderBy.Field)
}
return &DocumentConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewDocument(d *coredata.Document) *Document {
return &Document{
ID: d.ID,
Title: d.Title,
DocumentType: d.DocumentType,
}
}
func NewDocumentEdge(d *coredata.Document, orderField coredata.DocumentOrderField) *DocumentEdge {
return &DocumentEdge{
Node: NewDocument(d),
Cursor: d.CursorKey(orderField),
}
}

View File

@@ -0,0 +1,47 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
func NewDocumentVersionConnection(
p *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField],
) *DocumentVersionConnection {
edges := make([]*DocumentVersionEdge, len(p.Data))
for i, documentVersion := range p.Data {
edges[i] = NewDocumentVersionEdge(documentVersion, p.Cursor.OrderBy.Field)
}
return &DocumentVersionConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
return &DocumentVersion{
ID: dv.ID,
}
}
func NewDocumentVersionEdge(dv *coredata.DocumentVersion, orderField coredata.DocumentVersionOrderField) *DocumentVersionEdge {
return &DocumentVersionEdge{
Node: NewDocumentVersion(dv),
Cursor: dv.CursorKey(orderField),
}
}

View File

@@ -0,0 +1,26 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
)
func NewFramework(f *coredata.Framework) *Framework {
return &Framework{
ID: f.ID,
Name: f.Name,
}
}

View File

@@ -0,0 +1,44 @@
// 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 types
import (
"errors"
"io"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/getprobo/probo/pkg/gid"
)
func MarshalGIDScalar(id gid.GID) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
w.Write([]byte(strconv.Quote(id.String())))
})
}
func UnmarshalGIDScalar(v interface{}) (gid.GID, error) {
s, ok := v.(string)
if !ok {
return gid.Nil, errors.New("must be a string")
}
id, err := gid.ParseGID(s)
if err != nil {
return gid.Nil, err
}
return id, nil
}

View File

@@ -0,0 +1,26 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
)
func NewOrganization(o *coredata.Organization) *Organization {
return &Organization{
ID: o.ID,
Name: o.Name,
}
}

View File

@@ -0,0 +1,39 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/page"
"go.gearno.de/x/ref"
)
func NewPageInfo[T page.Paginable[O], O page.OrderField](p *page.Page[T, O]) *PageInfo {
var (
startCursor *page.CursorKey
endCursor *page.CursorKey
)
if len(p.Data) > 0 {
startCursor = ref.Ref(p.First().CursorKey(p.Cursor.OrderBy.Field))
endCursor = ref.Ref(p.Last().CursorKey(p.Cursor.OrderBy.Field))
}
return &PageInfo{
HasNextPage: p.Info.HasNext,
HasPreviousPage: p.Info.HasPrev,
StartCursor: startCursor,
EndCursor: endCursor,
}
}

View File

@@ -0,0 +1,26 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
)
func NewReport(r *coredata.Report) *Report {
return &Report{
ID: r.ID,
Filename: r.Filename,
}
}

View File

@@ -0,0 +1,27 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
)
func NewTrustCenter(tc *coredata.TrustCenter) *TrustCenter {
return &TrustCenter{
ID: tc.ID,
Active: tc.Active,
Slug: tc.Slug,
}
}

View File

@@ -0,0 +1,212 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package types
import (
"bytes"
"fmt"
"io"
"strconv"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
)
type Node interface {
IsNode()
GetID() gid.GID
}
type Audit struct {
ID gid.GID `json:"id"`
Framework *Framework `json:"framework"`
Report *Report `json:"report,omitempty"`
ReportURL *string `json:"reportUrl,omitempty"`
}
func (Audit) IsNode() {}
func (this Audit) GetID() gid.GID { return this.ID }
type AuditConnection struct {
Edges []*AuditEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type AuditEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Audit `json:"node"`
}
type Document struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
DocumentType coredata.DocumentType `json:"documentType"`
Versions *DocumentVersionConnection `json:"versions"`
}
func (Document) IsNode() {}
func (this Document) GetID() gid.GID { return this.ID }
type DocumentConnection struct {
Edges []*DocumentEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type DocumentEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Document `json:"node"`
}
type DocumentVersion struct {
ID gid.GID `json:"id"`
}
func (DocumentVersion) IsNode() {}
func (this DocumentVersion) GetID() gid.GID { return this.ID }
type DocumentVersionConnection struct {
Edges []*DocumentVersionEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type DocumentVersionEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *DocumentVersion `json:"node"`
}
type ExportDocumentVersionPDFInput struct {
DocumentVersionID gid.GID `json:"documentVersionId"`
}
type ExportDocumentVersionPDFPayload struct {
Data string `json:"data"`
}
type Framework struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
}
func (Framework) IsNode() {}
func (this Framework) GetID() gid.GID { return this.ID }
type Mutation struct {
}
type Organization struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"`
}
func (Organization) IsNode() {}
func (this Organization) GetID() gid.GID { return this.ID }
type PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor *page.CursorKey `json:"startCursor,omitempty"`
EndCursor *page.CursorKey `json:"endCursor,omitempty"`
}
type Query struct {
}
type Report struct {
ID gid.GID `json:"id"`
Filename string `json:"filename"`
DownloadURL *string `json:"downloadUrl,omitempty"`
}
func (Report) IsNode() {}
func (this Report) GetID() gid.GID { return this.ID }
type TrustCenter struct {
ID gid.GID `json:"id"`
Active bool `json:"active"`
Slug string `json:"slug"`
Organization *Organization `json:"organization"`
Documents *DocumentConnection `json:"documents"`
Audits *AuditConnection `json:"audits"`
Vendors *VendorConnection `json:"vendors"`
}
func (TrustCenter) IsNode() {}
func (this TrustCenter) GetID() gid.GID { return this.ID }
type Vendor struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Category coredata.VendorCategory `json:"category"`
WebsiteURL *string `json:"websiteUrl,omitempty"`
PrivacyPolicyURL *string `json:"privacyPolicyUrl,omitempty"`
}
func (Vendor) IsNode() {}
func (this Vendor) GetID() gid.GID { return this.ID }
type VendorConnection struct {
Edges []*VendorEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type VendorEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Vendor `json:"node"`
}
type Role string
const (
RoleNone Role = "NONE"
RoleUser Role = "USER"
)
var AllRole = []Role{
RoleNone,
RoleUser,
}
func (e Role) IsValid() bool {
switch e {
case RoleNone, RoleUser:
return true
}
return false
}
func (e Role) String() string {
return string(e)
}
func (e *Role) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = Role(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid Role", str)
}
return nil
}
func (e Role) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *Role) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e Role) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,51 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
func NewVendorConnection(
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
) *VendorConnection {
edges := make([]*VendorEdge, len(p.Data))
for i, vendor := range p.Data {
edges[i] = NewVendorEdge(vendor, p.Cursor.OrderBy.Field)
}
return &VendorConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewVendor(v *coredata.Vendor) *Vendor {
return &Vendor{
ID: v.ID,
Name: v.Name,
Category: v.Category,
WebsiteURL: v.WebsiteURL,
PrivacyPolicyURL: v.PrivacyPolicyURL,
}
}
func NewVendorEdge(v *coredata.Vendor, orderField coredata.VendorOrderField) *VendorEdge {
return &VendorEdge{
Node: NewVendor(v),
Cursor: v.CursorKey(orderField),
}
}

View File

@@ -0,0 +1,258 @@
package trust_v1
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.76
import (
"context"
"encoding/base64"
"fmt"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/server/api/trust/v1/schema"
"github.com/getprobo/probo/pkg/server/api/trust/v1/types"
)
// Framework is the resolver for the framework field.
func (r *auditResolver) Framework(ctx context.Context, obj *types.Audit) (*types.Framework, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
audit, err := trust.Audits.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load audit: %w", err)
}
framework, err := trust.Frameworks.Get(ctx, audit.FrameworkID)
if err != nil {
return nil, fmt.Errorf("cannot load framework: %w", err)
}
return types.NewFramework(framework), nil
}
// Report is the resolver for the report field.
func (r *auditResolver) Report(ctx context.Context, obj *types.Audit) (*types.Report, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
audit, err := trust.Audits.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load audit: %w", err)
}
if audit.ReportID == nil {
return nil, nil
}
report, err := trust.Reports.Get(ctx, *audit.ReportID)
if err != nil {
return nil, fmt.Errorf("cannot load report: %w", err)
}
return types.NewReport(report), nil
}
// ReportURL is the resolver for the reportUrl field.
func (r *auditResolver) ReportURL(ctx context.Context, obj *types.Audit) (*string, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
audit, err := trust.Audits.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load audit: %w", err)
}
if audit.ReportID == nil {
return nil, nil
}
url, err := trust.Audits.GenerateReportURL(ctx, obj.ID, 15*time.Minute)
if err != nil {
return nil, fmt.Errorf("cannot generate report URL: %w", err)
}
return url, nil
}
// Versions is the resolver for the versions field.
func (r *documentResolver) Versions(ctx context.Context, obj *types.Document, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.DocumentVersionConnection, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.DocumentVersionOrderField]{
Field: coredata.DocumentVersionOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
// For the public trust API, only return published versions
page, err := trust.Documents.ListVersions(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list document versions: %w", err)
}
// Filter to only published versions and create edges directly
publishedEdges := make([]*types.DocumentVersionEdge, 0)
for _, version := range page.Data {
if version.Status == coredata.DocumentStatusPublished {
edge := &types.DocumentVersionEdge{
Cursor: version.CursorKey(pageOrderBy.Field),
Node: types.NewDocumentVersion(version),
}
publishedEdges = append(publishedEdges, edge)
}
}
return &types.DocumentVersionConnection{
Edges: publishedEdges,
PageInfo: types.NewPageInfo(page),
}, nil
}
// ExportDocumentVersionPDF is the resolver for the exportDocumentVersionPDF field.
func (r *mutationResolver) ExportDocumentVersionPDF(ctx context.Context, input types.ExportDocumentVersionPDFInput) (*types.ExportDocumentVersionPDFPayload, error) {
trust := r.trustCenterSvc.WithTenant(input.DocumentVersionID.TenantID())
pdf, err := trust.Documents.ExportPDF(ctx, input.DocumentVersionID)
if err != nil {
return nil, fmt.Errorf("cannot export document version PDF: %w", err)
}
return &types.ExportDocumentVersionPDFPayload{
Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)),
}, nil
}
// LogoURL is the resolver for the logoUrl field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
return trust.Organizations.GenerateLogoURL(ctx, obj.ID, 1*time.Hour)
}
// TrustCenterBySlug is the resolver for the trustCenterBySlug field.
func (r *queryResolver) TrustCenterBySlug(ctx context.Context, slug string) (*types.TrustCenter, error) {
trust := r.trustCenterSvc.WithTenant(gid.NewTenantID())
trustCenter, err := trust.TrustCenters.GetBySlug(ctx, slug)
if err != nil {
return nil, nil
}
if !trustCenter.Active {
return nil, nil
}
result := types.NewTrustCenter(trustCenter)
orgTrust := r.trustCenterSvc.WithTenant(trustCenter.TenantID)
org, err := orgTrust.Organizations.Get(ctx, trustCenter.OrganizationID)
if err != nil {
return nil, fmt.Errorf("cannot get organization: %w", err)
}
result.Organization = types.NewOrganization(org)
return result, nil
}
// DownloadURL is the resolver for the downloadUrl field.
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
trust := r.TrustService(ctx, obj.ID.TenantID())
url, err := trust.Reports.GenerateDownloadURL(ctx, obj.ID, 5*time.Minute)
if err != nil {
return nil, fmt.Errorf("cannot generate download URL: %w", err)
}
return url, nil
}
// Organization is the resolver for the organization field.
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
return obj.Organization, nil
}
// Documents is the resolver for the documents field.
func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.DocumentConnection, error) {
trust := r.trustCenterSvc.WithTenant(obj.Organization.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.DocumentOrderField]{
Field: coredata.DocumentOrderFieldTitle,
Direction: page.OrderDirectionAsc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
documentPage, err := trust.Documents.ListForOrganizationId(ctx, obj.Organization.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list public documents: %w", err)
}
return types.NewDocumentConnection(documentPage), nil
}
// Audits is the resolver for the audits field.
func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.AuditConnection, error) {
trust := r.trustCenterSvc.WithTenant(obj.Organization.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.AuditOrderField]{
Field: coredata.AuditOrderFieldValidFrom,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
auditPage, err := trust.Audits.ListForOrganizationId(ctx, obj.Organization.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list public audits: %w", err)
}
return types.NewAuditConnection(auditPage), nil
}
// Vendors is the resolver for the vendors field.
func (r *trustCenterResolver) Vendors(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.VendorConnection, error) {
trust := r.trustCenterSvc.WithTenant(obj.Organization.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
Field: coredata.VendorOrderFieldName,
Direction: page.OrderDirectionAsc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
vendorPage, err := trust.Vendors.ListForOrganizationId(ctx, obj.Organization.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list public vendors: %w", err)
}
return types.NewVendorConnection(vendorPage), nil
}
// Audit returns schema.AuditResolver implementation.
func (r *Resolver) Audit() schema.AuditResolver { return &auditResolver{r} }
// Document returns schema.DocumentResolver implementation.
func (r *Resolver) Document() schema.DocumentResolver { return &documentResolver{r} }
// Mutation returns schema.MutationResolver implementation.
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
// Organization returns schema.OrganizationResolver implementation.
func (r *Resolver) Organization() schema.OrganizationResolver { return &organizationResolver{r} }
// Query returns schema.QueryResolver implementation.
func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} }
// Report returns schema.ReportResolver implementation.
func (r *Resolver) Report() schema.ReportResolver { return &reportResolver{r} }
// TrustCenter returns schema.TrustCenterResolver implementation.
func (r *Resolver) TrustCenter() schema.TrustCenterResolver { return &trustCenterResolver{r} }
type auditResolver struct{ *Resolver }
type documentResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type organizationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type reportResolver struct{ *Resolver }
type trustCenterResolver struct{ *Resolver }

View File

@@ -24,8 +24,8 @@ import (
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/saferedirect"
"github.com/getprobo/probo/pkg/server/api"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/server/web"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
@@ -37,7 +37,8 @@ type Config struct {
ExtraHeaderFields map[string]string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth console_v1.AuthConfig
Trust *trust.Service
Auth api.AuthConfig
ConnectorRegistry *connector.ConnectorRegistry
Agent *agents.Agent
SafeRedirect *saferedirect.SafeRedirect
@@ -59,6 +60,7 @@ func NewServer(cfg Config) (*Server, error) {
AllowedOrigins: cfg.AllowedOrigins,
Probo: cfg.Probo,
Usrmgr: cfg.Usrmgr,
Trust: cfg.Trust,
Auth: cfg.Auth,
ConnectorRegistry: cfg.ConnectorRegistry,
SafeRedirect: cfg.SafeRedirect,

View File

@@ -0,0 +1,99 @@
// 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 trust
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"go.gearno.de/kit/pg"
)
type AuditService struct {
svc *TenantService
}
func (s AuditService) Get(
ctx context.Context,
auditID gid.GID,
) (*coredata.Audit, error) {
audit := &coredata.Audit{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return audit.LoadByID(ctx, conn, s.svc.scope, auditID)
},
)
if err != nil {
return nil, err
}
return audit, nil
}
func (s AuditService) ListForOrganizationId(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.AuditOrderField],
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
var audits coredata.Audits
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
filter := coredata.NewAuditTrustCenterFilter()
err := audits.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load audits: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(audits, cursor), nil
}
func (s AuditService) GenerateReportURL(
ctx context.Context,
auditID gid.GID,
expiresIn time.Duration,
) (*string, error) {
audit, err := s.Get(ctx, auditID)
if err != nil {
return nil, fmt.Errorf("cannot get audit: %w", err)
}
if audit.ReportID == nil {
return nil, fmt.Errorf("audit has no report")
}
url, err := s.svc.Reports.GenerateDownloadURL(ctx, *audit.ReportID, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate report download URL: %w", err)
}
return url, nil
}

View File

@@ -0,0 +1,219 @@
// 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 trust
import (
"context"
"fmt"
"io"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/docgen"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/page"
"go.gearno.de/kit/pg"
)
type (
DocumentService struct {
svc *TenantService
html2pdfConverter *html2pdf.Converter
}
)
// ListVersions lists all versions of a document
func (s *DocumentService) ListVersions(
ctx context.Context,
documentID gid.GID,
cursor *page.Cursor[coredata.DocumentVersionOrderField],
) (*page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField], error) {
var documentVersions coredata.DocumentVersions
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return documentVersions.LoadByDocumentID(ctx, conn, s.svc.scope, documentID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(documentVersions, cursor), nil
}
func (s *DocumentService) ListForOrganizationId(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.DocumentOrderField],
) (*page.Page[*coredata.Document, coredata.DocumentOrderField], error) {
var documents coredata.Documents
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
filter := coredata.NewDocumentTrustCenterFilter()
err := documents.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load documents: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(documents, cursor), nil
}
func (s *DocumentService) ExportPDF(
ctx context.Context,
documentVersionID gid.GID,
) ([]byte, error) {
document := &coredata.Document{}
version := &coredata.DocumentVersion{}
owner := &coredata.People{}
publishedBy := &coredata.People{}
signatures := coredata.DocumentVersionSignatures{}
peopleMap := make(map[gid.GID]*coredata.People)
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := version.LoadByID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
if err := document.LoadByID(ctx, conn, s.svc.scope, version.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if !document.ShowOnTrustCenter {
return fmt.Errorf("document not visible on trust center")
}
if version.PublishedBy != nil {
if err := publishedBy.LoadByID(ctx, conn, s.svc.scope, *version.PublishedBy); err != nil {
return fmt.Errorf("cannot load published by person: %w", err)
}
}
cursor := page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.DocumentVersionSignatureOrderField]{
Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
)
if err := signatures.LoadByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil {
return fmt.Errorf("cannot load document version signatures: %w", err)
}
if err := owner.LoadByID(ctx, conn, s.svc.scope, document.OwnerID); err != nil {
return fmt.Errorf("cannot load document owner: %w", err)
}
// TODO: refactor this to use a single query
for _, sig := range signatures {
if _, ok := peopleMap[sig.SignedBy]; !ok {
people := &coredata.People{}
if err := people.LoadByID(ctx, conn, s.svc.scope, sig.SignedBy); err != nil {
return fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err)
}
peopleMap[sig.SignedBy] = people
}
if _, ok := peopleMap[sig.RequestedBy]; !ok {
people := &coredata.People{}
if err := people.LoadByID(ctx, conn, s.svc.scope, sig.RequestedBy); err != nil {
return fmt.Errorf("cannot load people %q: %w", sig.RequestedBy, err)
}
peopleMap[sig.RequestedBy] = people
}
}
return nil
},
)
if err != nil {
return nil, err
}
classification := docgen.ClassificationInternal
switch document.DocumentType {
case coredata.DocumentTypePolicy:
classification = docgen.ClassificationConfidential
case coredata.DocumentTypeISMS:
classification = docgen.ClassificationSecret
}
docData := docgen.DocumentData{
Title: version.Title,
Content: version.Content,
Version: version.VersionNumber,
Classification: classification,
Approver: owner.FullName,
Description: version.Changelog,
PublishedAt: version.PublishedAt,
PublishedBy: publishedBy.FullName,
Signatures: make([]docgen.SignatureData, len(signatures)),
}
for i, sig := range signatures {
docData.Signatures[i] = docgen.SignatureData{
SignedBy: peopleMap[sig.SignedBy].FullName,
SignedAt: sig.SignedAt,
State: sig.State,
RequestedAt: sig.RequestedAt,
RequestedBy: peopleMap[sig.RequestedBy].FullName,
}
}
htmlContent, err := docgen.RenderHTML(docData)
if err != nil {
return nil, fmt.Errorf("cannot generate HTML: %w", err)
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),
MarginRight: html2pdf.NewMarginInches(1.0),
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

@@ -0,0 +1,51 @@
// 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 trust
import (
"context"
"fmt"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"go.gearno.de/kit/pg"
)
type FrameworkService struct {
svc *TenantService
}
func (s FrameworkService) Get(
ctx context.Context,
frameworkID gid.GID,
) (*coredata.Framework, error) {
framework := &coredata.Framework{}
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
err := framework.LoadByID(ctx, conn, s.svc.scope, frameworkID)
if err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return framework, nil
}

View File

@@ -0,0 +1,97 @@
// 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 trust
import (
"context"
"fmt"
"net/url"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"go.gearno.de/kit/pg"
)
type OrganizationService struct {
svc *TenantService
}
func (s OrganizationService) Get(
ctx context.Context,
organizationID gid.GID,
) (*coredata.Organization, error) {
organization := &coredata.Organization{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := organization.LoadByID(
ctx,
conn,
s.svc.scope,
organizationID,
)
if err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organization, nil
}
func (s OrganizationService) GenerateLogoURL(
ctx context.Context,
organizationID gid.GID,
expiresIn time.Duration,
) (*string, error) {
organization, err := s.Get(ctx, organizationID)
if err != nil {
return nil, fmt.Errorf("cannot get organization: %w", err)
}
if organization.LogoObjectKey == "" {
return nil, nil
}
presignClient := s3.NewPresignClient(s.svc.s3)
encodedFilename := url.QueryEscape(organization.Name)
contentDisposition := fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s",
encodedFilename, encodedFilename)
presignedReq, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(organization.LogoObjectKey),
ResponseCacheControl: aws.String("max-age=3600, public"),
ResponseContentDisposition: aws.String(contentDisposition),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiresIn
})
if err != nil {
return nil, fmt.Errorf("cannot presign GetObject request: %w", err)
}
return &presignedReq.URL, nil
}

View File

@@ -0,0 +1,84 @@
// 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 trust
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"go.gearno.de/kit/pg"
)
type ReportService struct {
svc *TenantService
}
func (s ReportService) Get(
ctx context.Context,
reportID gid.GID,
) (*coredata.Report, error) {
report := &coredata.Report{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := report.LoadByID(ctx, conn, s.svc.scope, reportID)
if err != nil {
return fmt.Errorf("cannot load report: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return report, nil
}
func (s ReportService) GenerateDownloadURL(
ctx context.Context,
reportID gid.GID,
expiresIn time.Duration,
) (*string, error) {
report, err := s.Get(ctx, reportID)
if err != nil {
return nil, fmt.Errorf("cannot get report: %w", err)
}
presignClient := s3.NewPresignClient(s.svc.s3)
presignedReq, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(report.ObjectKey),
ResponseCacheControl: aws.String("max-age=3600, public"),
ResponseContentType: aws.String(report.MimeType),
ResponseContentDisposition: aws.String(fmt.Sprintf("attachment; filename=\"%s\"", report.Filename)),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiresIn
})
if err != nil {
return nil, fmt.Errorf("cannot presign GetObject request: %w", err)
}
return &presignedReq.URL, nil
}

108
pkg/trust/service.go Normal file
View File

@@ -0,0 +1,108 @@
// 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 trust
import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
type (
Service struct {
pg *pg.Client
s3 *s3.Client
bucket string
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
usrmgr *usrmgr.Service
html2pdfConverter *html2pdf.Converter
}
TenantService struct {
pg *pg.Client
s3 *s3.Client
bucket string
scope coredata.Scoper
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
usrmgr *usrmgr.Service
html2pdfConverter *html2pdf.Converter
TrustCenters *TrustCenterService
Documents *DocumentService
Audits *AuditService
Vendors *VendorService
Frameworks *FrameworkService
TrustCenterAccesses *TrustCenterAccessService
Reports *ReportService
Organizations *OrganizationService
}
)
func NewService(
pgClient *pg.Client,
s3Client *s3.Client,
bucket string,
encryptionKey cipher.EncryptionKey,
tokenSecret string,
usrmgr *usrmgr.Service,
html2pdfConverter *html2pdf.Converter,
) *Service {
return &Service{
pg: pgClient,
s3: s3Client,
bucket: bucket,
encryptionKey: encryptionKey,
tokenSecret: tokenSecret,
usrmgr: usrmgr,
html2pdfConverter: html2pdfConverter,
}
}
func (s *Service) GetEncryptionKey() cipher.EncryptionKey {
return s.encryptionKey
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService := &TenantService{
pg: s.pg,
s3: s.s3,
bucket: s.bucket,
scope: coredata.NewScope(tenantID),
proboSvc: s.proboSvc,
encryptionKey: s.encryptionKey,
tokenSecret: s.tokenSecret,
usrmgr: s.usrmgr,
html2pdfConverter: s.html2pdfConverter,
}
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
tenantService.Documents = &DocumentService{svc: tenantService, html2pdfConverter: s.html2pdfConverter}
tenantService.Audits = &AuditService{svc: tenantService}
tenantService.Vendors = &VendorService{svc: tenantService}
tenantService.Frameworks = &FrameworkService{svc: tenantService}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, usrmgr: s.usrmgr}
tenantService.Reports = &ReportService{svc: tenantService}
tenantService.Organizations = &OrganizationService{svc: tenantService}
return tenantService
}

View File

@@ -0,0 +1,89 @@
// 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 trust
import (
"context"
"fmt"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
type (
TrustCenterAccessService struct {
svc *TenantService
usrmgr *usrmgr.Service
}
)
const (
TokenTypeTrustCenterAccess = "trust_center_access"
)
func (s TrustCenterAccessService) ValidateToken(
ctx context.Context,
tokenString string,
) (*probo.TrustCenterAccessData, error) {
token, err := statelesstoken.ValidateToken[probo.TrustCenterAccessData](
s.svc.tokenSecret,
TokenTypeTrustCenterAccess,
tokenString,
)
if err != nil {
return nil, fmt.Errorf("cannot validate trust center access token: %w", err)
}
access := &coredata.TrustCenterAccess{}
err = s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
err := access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, token.Data.TrustCenterID, token.Data.Email)
if err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
if !access.Active {
return nil, fmt.Errorf("access has been revoked")
}
return &token.Data, nil
}
func (s TrustCenterAccessService) IsAccessActive(
ctx context.Context,
trustCenterID gid.GID,
email string,
) (bool, error) {
access := &coredata.TrustCenterAccess{}
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
})
if err != nil {
return false, fmt.Errorf("cannot load trust center access: %w", err)
}
return access.Active, nil
}

View File

@@ -0,0 +1,53 @@
// 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 trust
import (
"context"
"fmt"
"github.com/getprobo/probo/pkg/coredata"
"go.gearno.de/kit/pg"
)
type TrustCenterService struct {
svc *TenantService
}
func (s TrustCenterService) GetBySlug(
ctx context.Context,
slug string,
) (*coredata.TrustCenter, error) {
trustCenter := &coredata.TrustCenter{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := trustCenter.LoadBySlug(ctx, conn, slug)
if err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return trustCenter, nil
}

View File

@@ -0,0 +1,81 @@
// 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 trust
import (
"context"
"fmt"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"go.gearno.de/kit/pg"
)
type VendorService struct {
svc *TenantService
}
func (s VendorService) Get(
ctx context.Context,
vendorID gid.GID,
) (*coredata.Vendor, error) {
vendor := &coredata.Vendor{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := vendor.LoadByID(ctx, conn, s.svc.scope, vendorID)
if err != nil {
return fmt.Errorf("cannot load vendor: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return vendor, nil
}
func (s VendorService) ListForOrganizationId(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.VendorOrderField],
) (*page.Page[*coredata.Vendor, coredata.VendorOrderField], error) {
var vendors coredata.Vendors
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
filter := coredata.NewVendorTrustCenterFilter()
err := vendors.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load vendors: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(vendors, cursor), nil
}

View File

@@ -123,6 +123,19 @@ var (
[1] %s
`
trustCenterAccessEmailSubject = "Trust Center Access Invitation - %s"
trustCenterAccessEmailTemplate = `
You have been granted access to %s's Trust Center!
Click the link below to access it:
[1] %s
This link will expire in 7 days.
If the link above doesn't work, copy and paste the entire URL into your browser.
`
)
func (e ErrInvalidCredentials) Error() string {
@@ -909,3 +922,25 @@ func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassw
},
)
}
func (s Service) SendTrustCenterAccessEmail(
ctx context.Context,
name string,
email string,
companyName string,
accessURL string,
) error {
accessEmail := coredata.NewEmail(
name,
email,
fmt.Sprintf(trustCenterAccessEmailSubject, companyName),
fmt.Sprintf(trustCenterAccessEmailTemplate, companyName, accessURL),
)
return s.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := accessEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert access email: %w", err)
}
return nil
})
}