Move evidence to measure level

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-05-03 16:57:51 -07:00
parent 5b65109387
commit 3bfb3ba485
14 changed files with 4568 additions and 1633 deletions

View File

@@ -29,7 +29,8 @@ import (
type (
Evidence struct {
ID gid.GID `db:"id"`
TaskID gid.GID `db:"task_id"`
MeasureID gid.GID `db:"measure_id"`
TaskID *gid.GID `db:"task_id"`
State EvidenceState `db:"state"`
ReferenceID string `db:"reference_id"`
Type EvidenceType `db:"type"`
@@ -65,6 +66,7 @@ INSERT INTO
evidences (
tenant_id,
id,
measure_id,
task_id,
reference_id,
object_key,
@@ -81,6 +83,7 @@ INSERT INTO
VALUES (
@tenant_id,
@evidence_id,
@measure_id,
@task_id,
@reference_id,
@object_key,
@@ -104,6 +107,7 @@ WHERE evidences.state = 'REQUESTED';
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"evidence_id": e.ID,
"measure_id": e.MeasureID,
"task_id": e.TaskID,
"reference_id": e.ReferenceID,
"object_key": e.ObjectKey,
@@ -131,6 +135,7 @@ INSERT INTO
evidences (
tenant_id,
id,
measure_id,
task_id,
reference_id,
object_key,
@@ -147,6 +152,7 @@ INSERT INTO
VALUES (
@tenant_id,
@evidence_id,
@measure_id,
@task_id,
@reference_id,
@object_key,
@@ -165,6 +171,7 @@ VALUES (
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"evidence_id": e.ID,
"measure_id": e.MeasureID,
"task_id": e.TaskID,
"reference_id": e.ReferenceID,
"object_key": e.ObjectKey,
@@ -192,6 +199,7 @@ func (e *Evidence) LoadByID(
SELECT
id,
task_id,
measure_id,
reference_id,
state,
type,
@@ -231,6 +239,58 @@ LIMIT 1;
return nil
}
func (e *Evidences) LoadByMeasureID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
measureID gid.GID,
cursor *page.Cursor[EvidenceOrderField],
) error {
q := `
SELECT
id,
measure_id,
task_id,
reference_id,
state,
type,
object_key,
mime_type,
size,
filename,
url,
description,
created_at,
updated_at
FROM
evidences
WHERE
%s
AND measure_id = @measure_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"measure_id": measureID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query evidence: %w", err)
}
evidences, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Evidence])
if err != nil {
return fmt.Errorf("cannot collect evidence: %w", err)
}
*e = evidences
return nil
}
func (e *Evidences) LoadByTaskID(
ctx context.Context,
conn pg.Conn,
@@ -241,6 +301,7 @@ func (e *Evidences) LoadByTaskID(
q := `
SELECT
id,
measure_id,
task_id,
reference_id,
state,

View File

@@ -0,0 +1,9 @@
ALTER TABLE evidences ALTER COLUMN task_id DROP NOT NULL;
ALTER TABLE evidences ADD COLUMN measure_id TEXT REFERENCES measures(id);
UPDATE evidences e
SET measure_id = t.measure_id
FROM tasks t
WHERE e.task_id = t.id AND e.measure_id IS NULL;
ALTER TABLE evidences ALTER COLUMN measure_id SET NOT NULL;

View File

@@ -37,8 +37,16 @@ type (
svc *TenantService
}
File struct {
Content io.Reader
Filename string
Size int64
ContentType string
}
RequestEvidenceRequest struct {
TaskID gid.GID
MeasureID *gid.GID
TaskID *gid.GID
Type coredata.EvidenceType
Name string
Description string
@@ -51,13 +59,14 @@ type (
Filename *string
}
CreateEvidenceRequest struct {
TaskID gid.GID
Name string
Type coredata.EvidenceType
File io.Reader
URL string
Description string
UploadTaskEvidenceRequest struct {
TaskID gid.GID
File File
}
UploadMeasureEvidenceRequest struct {
MeasureID gid.GID
File File
}
)
@@ -90,7 +99,6 @@ func (s EvidenceService) Request(
evidence := &coredata.Evidence{
ID: evidenceID,
TaskID: req.TaskID,
State: coredata.EvidenceStateRequested,
Type: req.Type,
Filename: req.Name,
@@ -99,9 +107,23 @@ func (s EvidenceService) Request(
UpdatedAt: now,
}
err := s.svc.pg.WithConn(
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
task := &coredata.Task{}
if req.TaskID != nil {
if err := task.LoadByID(ctx, conn, s.svc.scope, *req.TaskID); err != nil {
return fmt.Errorf("cannot load task: %w", err)
}
evidence.TaskID = req.TaskID
evidence.MeasureID = task.MeasureID
} else if req.MeasureID != nil {
evidence.MeasureID = *req.MeasureID
} else {
return fmt.Errorf("measure id or task id is required")
}
return evidence.Insert(ctx, conn, s.svc.scope)
},
)
@@ -183,9 +205,9 @@ func (s EvidenceService) Fulfill(
return evidence, nil
}
func (s EvidenceService) Create(
func (s EvidenceService) UploadTaskEvidence(
ctx context.Context,
req CreateEvidenceRequest,
req UploadTaskEvidenceRequest,
) (*coredata.Evidence, error) {
now := time.Now()
evidenceID := gid.New(s.svc.scope.GetTenantID(), coredata.EvidenceEntityType)
@@ -197,66 +219,130 @@ func (s EvidenceService) Create(
evidence := &coredata.Evidence{
ID: evidenceID,
TaskID: req.TaskID,
TaskID: &req.TaskID,
State: coredata.EvidenceStateFulfilled,
ReferenceID: "custom-evidence-" + referenceID.String(),
Type: req.Type,
Filename: req.Name,
URL: req.URL,
Description: req.Description,
Type: coredata.EvidenceTypeFile,
Filename: req.File.Filename,
CreatedAt: now,
UpdatedAt: now,
}
if req.Type == coredata.EvidenceTypeFile {
contentType := "application/octet-stream"
if req.Name != "" {
if detectedType := mime.TypeByExtension(filepath.Ext(req.Name)); detectedType != "" {
contentType = detectedType
}
}
objectKey, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("cannot generate object key: %w", err)
}
_, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(objectKey.String()),
Body: req.File,
ContentType: aws.String(contentType),
})
if err != nil {
return nil, fmt.Errorf("cannot upload file to S3: %w", err)
}
headOutput, err := s.svc.s3.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(objectKey.String()),
})
if err != nil {
return nil, fmt.Errorf("cannot get object metadata: %w", err)
}
evidence.ObjectKey = objectKey.String()
evidence.MimeType = contentType
evidence.Size = uint64(*headOutput.ContentLength)
} else if req.Type == coredata.EvidenceTypeLink {
evidence.MimeType = "text/uri-list"
evidence.Size = uint64(len(req.URL))
evidence.ObjectKey = ""
// TODO validate content type
if req.File.ContentType == "" {
req.File.ContentType = "application/octet-stream"
}
task := &coredata.Task{}
objectKey, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("cannot generate object key: %w", err)
}
_, err = s.svc.s3.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(objectKey.String()),
Body: req.File.Content,
ContentType: aws.String(req.File.ContentType),
},
)
if err != nil {
return nil, fmt.Errorf("cannot upload file to S3: %w", err)
}
evidence.ObjectKey = objectKey.String()
evidence.MimeType = req.File.ContentType
evidence.Size = uint64(req.File.Size)
err = s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
task := &coredata.Task{}
if err := task.LoadByID(ctx, conn, s.svc.scope, req.TaskID); err != nil {
return fmt.Errorf("cannot load task %q: %w", req.TaskID, err)
}
evidence.MeasureID = task.MeasureID
if err := evidence.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert evidence: %w", err)
}
return nil
},
)
if err != nil {
// TODO try do delete file from s3 if it's a file type
return nil, err
}
return evidence, nil
}
func (s EvidenceService) UploadMeasureEvidence(
ctx context.Context,
req UploadMeasureEvidenceRequest,
) (*coredata.Evidence, error) {
now := time.Now()
evidenceID := gid.New(s.svc.scope.GetTenantID(), coredata.EvidenceEntityType)
referenceID, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("cannot generate reference id: %w", err)
}
evidence := &coredata.Evidence{
ID: evidenceID,
MeasureID: req.MeasureID,
State: coredata.EvidenceStateFulfilled,
ReferenceID: "custom-evidence-" + referenceID.String(),
Type: coredata.EvidenceTypeFile,
Filename: req.File.Filename,
CreatedAt: now,
UpdatedAt: now,
}
// TODO validate content type
if req.File.ContentType == "" {
req.File.ContentType = "application/octet-stream"
}
objectKey, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("cannot generate object key: %w", err)
}
_, err = s.svc.s3.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(objectKey.String()),
Body: req.File.Content,
ContentType: aws.String(req.File.ContentType),
},
)
if err != nil {
return nil, fmt.Errorf("cannot upload file to S3: %w", err)
}
evidence.ObjectKey = objectKey.String()
evidence.MimeType = req.File.ContentType
evidence.Size = uint64(req.File.Size)
err = s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
measure := &coredata.Measure{}
if err := measure.LoadByID(ctx, conn, s.svc.scope, req.MeasureID); err != nil {
return fmt.Errorf("cannot load measure %q: %w", req.MeasureID, err)
}
evidence.MeasureID = req.MeasureID
if err := evidence.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert evidence: %w", err)
}
@@ -310,6 +396,33 @@ func (s EvidenceService) GenerateFileURL(
return &presignedReq.URL, nil
}
func (s EvidenceService) ListForMeasureID(
ctx context.Context,
measureID gid.GID,
cursor *page.Cursor[coredata.EvidenceOrderField],
) (*page.Page[*coredata.Evidence, coredata.EvidenceOrderField], error) {
var evidences coredata.Evidences
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return evidences.LoadByMeasureID(
ctx,
conn,
s.svc.scope,
measureID,
cursor,
)
},
)
if err != nil {
return nil, err
}
return page.NewPage(evidences, cursor), nil
}
func (s EvidenceService) ListForTaskID(
ctx context.Context,
taskID gid.GID,

View File

@@ -188,7 +188,7 @@ func (s MeasureService) Import(
evidence := &coredata.Evidence{
State: coredata.EvidenceStateRequested,
ID: evidenceID,
TaskID: task.ID,
TaskID: &task.ID,
ReferenceID: req.Measures[i].Tasks[j].RequestedEvidences[k].ReferenceID,
Type: req.Measures[i].Tasks[j].RequestedEvidences[k].Type,
Description: req.Measures[i].Tasks[j].RequestedEvidences[k].Name,

View File

@@ -596,6 +596,14 @@ type Measure implements Node {
description: String!
state: MeasureState!
evidences(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: EvidenceOrder
): EvidenceConnection! @goField(forceResolver: true)
tasks(
first: Int
after: CursorKey
@@ -987,8 +995,9 @@ type Mutation {
# Evidence mutations
requestEvidence(input: RequestEvidenceInput!): RequestEvidencePayload!
fulfillEvidence(input: FulfillEvidenceInput!): FulfillEvidencePayload!
createEvidence(input: CreateEvidenceInput!): CreateEvidencePayload!
deleteEvidence(input: DeleteEvidenceInput!): DeleteEvidencePayload!
uploadTaskEvidence(input: UploadTaskEvidenceInput!): UploadTaskEvidencePayload!
uploadMeasureEvidence(input: UploadMeasureEvidenceInput!): UploadMeasureEvidencePayload!
# Vendor Compliance Report mutations
uploadVendorComplianceReport(
@@ -1651,4 +1660,22 @@ input SendSigningNotificationsInput {
type SendSigningNotificationsPayload {
success: Boolean!
}
}
type UploadTaskEvidencePayload {
evidenceEdge: EvidenceEdge!
}
type UploadMeasureEvidencePayload {
evidenceEdge: EvidenceEdge!
}
input UploadTaskEvidenceInput {
taskId: ID!
file: Upload!
}
input UploadMeasureEvidenceInput {
measureId: ID!
file: Upload!
}

File diff suppressed because it is too large Load Diff

View File

@@ -473,6 +473,7 @@ type Measure struct {
Name string `json:"name"`
Description string `json:"description"`
State coredata.MeasureState `json:"state"`
Evidences *EvidenceConnection `json:"evidences"`
Tasks *TaskConnection `json:"tasks"`
Risks *RiskConnection `json:"risks"`
Controls *ControlConnection `json:"controls"`
@@ -886,6 +887,24 @@ type UpdateVendorPayload struct {
Vendor *Vendor `json:"vendor"`
}
type UploadMeasureEvidenceInput struct {
MeasureID gid.GID `json:"measureId"`
File graphql.Upload `json:"file"`
}
type UploadMeasureEvidencePayload struct {
EvidenceEdge *EvidenceEdge `json:"evidenceEdge"`
}
type UploadTaskEvidenceInput struct {
TaskID gid.GID `json:"taskId"`
File graphql.Upload `json:"file"`
}
type UploadTaskEvidencePayload struct {
EvidenceEdge *EvidenceEdge `json:"evidenceEdge"`
}
type UploadVendorComplianceReportInput struct {
VendorID gid.GID `json:"vendorId"`
ReportDate time.Time `json:"reportDate"`

View File

@@ -112,6 +112,31 @@ func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework,
return types.NewControlConnection(page), nil
}
// Evidences is the resolver for the evidences field.
func (r *measureResolver) Evidences(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.EvidenceOrderField]{
Field: coredata.EvidenceOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.EvidenceOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := svc.Evidences.ListForMeasureID(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list measure evidences: %w", err)
}
return types.NewEvidenceConnection(page), nil
}
// Tasks is the resolver for the tasks field.
func (r *measureResolver) Tasks(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TaskOrderBy) (*types.TaskConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -875,7 +900,7 @@ func (r *mutationResolver) RequestEvidence(ctx context.Context, input types.Requ
evidence, err := svc.Evidences.Request(
ctx,
probo.RequestEvidenceRequest{
TaskID: input.TaskID,
TaskID: &input.TaskID,
Name: input.Name,
Type: input.Type,
Description: input.Description,
@@ -917,39 +942,6 @@ func (r *mutationResolver) FulfillEvidence(ctx context.Context, input types.Fulf
}, nil
}
// CreateEvidence is the resolver for the createEvidence field.
func (r *mutationResolver) CreateEvidence(ctx context.Context, input types.CreateEvidenceInput) (*types.CreateEvidencePayload, error) {
svc := GetTenantService(ctx, r.proboSvc, input.TaskID.TenantID())
req := probo.CreateEvidenceRequest{
TaskID: input.TaskID,
Name: input.Name,
Type: input.Type,
Description: input.Description,
}
if input.Type == coredata.EvidenceTypeFile {
if input.File == nil {
return nil, fmt.Errorf("file is required for FILE type evidence")
}
req.File = input.File.File
} else if input.Type == coredata.EvidenceTypeLink {
if input.URL == nil || *input.URL == "" {
return nil, fmt.Errorf("URL is required for LINK type evidence")
}
req.URL = *input.URL
}
evidence, err := svc.Evidences.Create(ctx, req)
if err != nil {
panic(fmt.Errorf("failed to create evidence: %w", err))
}
return &types.CreateEvidencePayload{
EvidenceEdge: types.NewEvidenceEdge(evidence, coredata.EvidenceOrderFieldCreatedAt),
}, nil
}
// DeleteEvidence is the resolver for the deleteEvidence field.
func (r *mutationResolver) DeleteEvidence(ctx context.Context, input types.DeleteEvidenceInput) (*types.DeleteEvidencePayload, error) {
svc := GetTenantService(ctx, r.proboSvc, input.EvidenceID.TenantID())
@@ -964,6 +956,56 @@ func (r *mutationResolver) DeleteEvidence(ctx context.Context, input types.Delet
}, nil
}
// UploadTaskEvidence is the resolver for the uploadTaskEvidence field.
func (r *mutationResolver) UploadTaskEvidence(ctx context.Context, input types.UploadTaskEvidenceInput) (*types.UploadTaskEvidencePayload, error) {
svc := GetTenantService(ctx, r.proboSvc, input.TaskID.TenantID())
evidence, err := svc.Evidences.UploadTaskEvidence(
ctx,
probo.UploadTaskEvidenceRequest{
TaskID: input.TaskID,
File: probo.File{
Content: input.File.File,
Filename: input.File.Filename,
Size: input.File.Size,
ContentType: input.File.ContentType,
},
},
)
if err != nil {
panic(fmt.Errorf("cannot upload task evidence: %w", err))
}
return &types.UploadTaskEvidencePayload{
EvidenceEdge: types.NewEvidenceEdge(evidence, coredata.EvidenceOrderFieldCreatedAt),
}, nil
}
// UploadMeasureEvidence is the resolver for the uploadMeasureEvidence field.
func (r *mutationResolver) UploadMeasureEvidence(ctx context.Context, input types.UploadMeasureEvidenceInput) (*types.UploadMeasureEvidencePayload, error) {
svc := GetTenantService(ctx, r.proboSvc, input.MeasureID.TenantID())
evidence, err := svc.Evidences.UploadMeasureEvidence(
ctx,
probo.UploadMeasureEvidenceRequest{
MeasureID: input.MeasureID,
File: probo.File{
Content: input.File.File,
Filename: input.File.Filename,
Size: input.File.Size,
ContentType: input.File.ContentType,
},
},
)
if err != nil {
panic(fmt.Errorf("cannot upload measure evidence: %w", err))
}
return &types.UploadMeasureEvidencePayload{
EvidenceEdge: types.NewEvidenceEdge(evidence, coredata.EvidenceOrderFieldCreatedAt),
}, nil
}
// UploadVendorComplianceReport is the resolver for the uploadVendorComplianceReport field.
func (r *mutationResolver) UploadVendorComplianceReport(ctx context.Context, input types.UploadVendorComplianceReportInput) (*types.UploadVendorComplianceReportPayload, error) {
svc := GetTenantService(ctx, r.proboSvc, input.VendorID.TenantID())