diff --git a/pkg/api/console/v1/schema.graphql b/pkg/api/console/v1/schema.graphql index 42c9c1ea9..a1daf0aa7 100644 --- a/pkg/api/console/v1/schema.graphql +++ b/pkg/api/console/v1/schema.graphql @@ -25,6 +25,12 @@ enum TaskState { DONE } +enum EvidenceState { + VALID + INVALID + EXPIRED +} + type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! @@ -242,6 +248,7 @@ type EvidenceEdge { type Evidence implements Node { id: ID! fileUrl: String! + state: EvidenceState! createdAt: Datetime! updatedAt: Datetime! } diff --git a/pkg/api/console/v1/schema/schema.go b/pkg/api/console/v1/schema/schema.go index 556564b76..d88f0eea6 100644 --- a/pkg/api/console/v1/schema/schema.go +++ b/pkg/api/console/v1/schema/schema.go @@ -96,6 +96,7 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int FileURL func(childComplexity int) int ID func(childComplexity int) int + State func(childComplexity int) int UpdatedAt func(childComplexity int) int } @@ -450,6 +451,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Evidence.ID(childComplexity), true + case "Evidence.state": + if e.complexity.Evidence.State == nil { + break + } + + return e.complexity.Evidence.State(childComplexity), true + case "Evidence.updatedAt": if e.complexity.Evidence.UpdatedAt == nil { break @@ -1070,6 +1078,12 @@ enum TaskState { DONE } +enum EvidenceState { + VALID + INVALID + EXPIRED +} + type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! @@ -1287,6 +1301,7 @@ type EvidenceEdge { type Evidence implements Node { id: ID! fileUrl: String! + state: EvidenceState! createdAt: Datetime! updatedAt: Datetime! } @@ -3010,6 +3025,44 @@ func (ec *executionContext) fieldContext_Evidence_fileUrl(_ context.Context, fie return fc, nil } +func (ec *executionContext) _Evidence_state(ctx context.Context, field graphql.CollectedField, obj *types.Evidence) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Evidence_state(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.State, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(types.EvidenceState) + fc.Result = res + return ec.marshalNEvidenceState2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐEvidenceState(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Evidence_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Evidence", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type EvidenceState does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Evidence_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.Evidence) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Evidence_createdAt(ctx, field) if err != nil { @@ -3253,6 +3306,8 @@ func (ec *executionContext) fieldContext_EvidenceEdge_node(_ context.Context, fi return ec.fieldContext_Evidence_id(ctx, field) case "fileUrl": return ec.fieldContext_Evidence_fileUrl(ctx, field) + case "state": + return ec.fieldContext_Evidence_state(ctx, field) case "createdAt": return ec.fieldContext_Evidence_createdAt(ctx, field) case "updatedAt": @@ -7938,6 +7993,11 @@ func (ec *executionContext) _Evidence(ctx context.Context, sel ast.SelectionSet, if out.Values[i] == graphql.Null { out.Invalids++ } + case "state": + out.Values[i] = ec._Evidence_state(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createdAt": out.Values[i] = ec._Evidence_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -9750,6 +9810,16 @@ func (ec *executionContext) marshalNEvidenceEdge2ᚖgithubᚗcomᚋgetproboᚋpr return ec._EvidenceEdge(ctx, sel, v) } +func (ec *executionContext) unmarshalNEvidenceState2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐEvidenceState(ctx context.Context, v any) (types.EvidenceState, error) { + var res types.EvidenceState + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNEvidenceState2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐEvidenceState(ctx context.Context, sel ast.SelectionSet, v types.EvidenceState) graphql.Marshaler { + return v +} + func (ec *executionContext) marshalNFramework2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐFramework(ctx context.Context, sel ast.SelectionSet, v *types.Framework) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { diff --git a/pkg/api/console/v1/types/evidence.go b/pkg/api/console/v1/types/evidence.go index e578fe465..91becadfb 100644 --- a/pkg/api/console/v1/types/evidence.go +++ b/pkg/api/console/v1/types/evidence.go @@ -39,11 +39,12 @@ func NewEvidenceEdge(e *coredata.Evidence) *EvidenceEdge { } } -func NewEvidence(c *coredata.Evidence) *Evidence { +func NewEvidence(e *coredata.Evidence) *Evidence { return &Evidence{ - ID: c.ID, + ID: e.ID, + State: EvidenceState(e.State.String()), FileURL: "", - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } } diff --git a/pkg/api/console/v1/types/types.go b/pkg/api/console/v1/types/types.go index 0773bc1de..5896bff00 100644 --- a/pkg/api/console/v1/types/types.go +++ b/pkg/api/console/v1/types/types.go @@ -61,10 +61,11 @@ type ControlStateTransitionEdge struct { } type Evidence struct { - ID gid.GID `json:"id"` - FileURL string `json:"fileUrl"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID gid.GID `json:"id"` + FileURL string `json:"fileUrl"` + State EvidenceState `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (Evidence) IsNode() {} @@ -255,6 +256,49 @@ func (e ControlState) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type EvidenceState string + +const ( + EvidenceStateValid EvidenceState = "VALID" + EvidenceStateInvalid EvidenceState = "INVALID" + EvidenceStateExpired EvidenceState = "EXPIRED" +) + +var AllEvidenceState = []EvidenceState{ + EvidenceStateValid, + EvidenceStateInvalid, + EvidenceStateExpired, +} + +func (e EvidenceState) IsValid() bool { + switch e { + case EvidenceStateValid, EvidenceStateInvalid, EvidenceStateExpired: + return true + } + return false +} + +func (e EvidenceState) String() string { + return string(e) +} + +func (e *EvidenceState) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = EvidenceState(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid EvidenceState", str) + } + return nil +} + +func (e EvidenceState) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type TaskState string const ( diff --git a/pkg/probo/coredata/entity_type_reg.go b/pkg/probo/coredata/entity_type_reg.go index 41d8e505c..3dc0e0528 100644 --- a/pkg/probo/coredata/entity_type_reg.go +++ b/pkg/probo/coredata/entity_type_reg.go @@ -24,4 +24,5 @@ const ( TaskStateTransitionEntityType VendorEntityType PeopleEntityType + EvidenceStateTransitionEntityType ) diff --git a/pkg/probo/coredata/evidence.go b/pkg/probo/coredata/evidence.go index c5f51b6ee..717375fae 100644 --- a/pkg/probo/coredata/evidence.go +++ b/pkg/probo/coredata/evidence.go @@ -31,6 +31,7 @@ type ( Evidence struct { ID gid.GID TaskID gid.GID + State EvidenceState ObjectKey string MimeType string Size uint64 @@ -49,6 +50,7 @@ func (e *Evidence) scan(r pgx.Row) error { return r.Scan( &e.ID, &e.TaskID, + &e.State, &e.ObjectKey, &e.MimeType, &e.Size, @@ -65,9 +67,22 @@ func (e *Evidences) LoadByTaskID( cursor *page.Cursor, ) error { q := ` +WITH + evidence_states AS ( + SELECT + evidence_id, + to_state AS state, + reason, + RANK() OVER w + FROM + evidence_state_transitions + WINDOW + w AS (PARTITION BY evidence_id ORDER BY created_at DESC) + ) SELECT id, task_id, + es.state, object_key, mime_type, size, @@ -75,9 +90,12 @@ SELECT updated_at FROM evidences +INNER JOIN + evidence_states es ON es.evidence_id = evidences.id WHERE %s AND task_id = @task_id + AND es.rank = 1 AND %s ` diff --git a/pkg/probo/coredata/evidence_state.go b/pkg/probo/coredata/evidence_state.go new file mode 100644 index 000000000..b64b57df7 --- /dev/null +++ b/pkg/probo/coredata/evidence_state.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025 Probo Inc . +// +// 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 ( + "database/sql/driver" + "fmt" +) + +type ( + EvidenceState uint8 +) + +const ( + EvidenceStateValid EvidenceState = iota + EvidenceStateInvalid + EvidenceStateNotExpired +) + +func (es EvidenceState) MarshalText() ([]byte, error) { + return []byte(es.String()), nil +} + +func (es *EvidenceState) UnmarshalText(data []byte) error { + val := string(data) + + switch val { + case EvidenceStateValid.String(): + *es = EvidenceStateValid + case EvidenceStateInvalid.String(): + *es = EvidenceStateInvalid + case EvidenceStateNotExpired.String(): + *es = EvidenceStateNotExpired + default: + return fmt.Errorf("invalid EvidenceState value: %q", val) + } + + return nil +} + +func (es EvidenceState) String() string { + var val string + + switch es { + case EvidenceStateValid: + val = "VALID" + case EvidenceStateInvalid: + val = "INVALID" + case EvidenceStateNotExpired: + val = "EXPIRED" + } + + return val +} + +func (es *EvidenceState) Scan(value any) error { + val, ok := value.(string) + if !ok { + return fmt.Errorf("invalid scan source for EvidenceState, expected string got %T", value) + } + + return es.UnmarshalText([]byte(val)) +} + +func (es EvidenceState) Value() (driver.Value, error) { + return es.String(), nil +} diff --git a/pkg/probo/coredata/evidence_state_transition.go b/pkg/probo/coredata/evidence_state_transition.go new file mode 100644 index 000000000..6e70efb98 --- /dev/null +++ b/pkg/probo/coredata/evidence_state_transition.go @@ -0,0 +1,107 @@ +// Copyright (c) 2025 Probo Inc . +// +// 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" + + "github.com/getprobo/probo/pkg/probo/coredata/gid" + "github.com/getprobo/probo/pkg/probo/coredata/page" + "github.com/jackc/pgx/v5" + "go.gearno.de/crypto/uuid" + "go.gearno.de/kit/pg" +) + +type ( + EvidenceStateTransition struct { + StateTransition[EvidenceState] + + EvidenceID gid.GID + } + + EvidenceStateTransitions []*EvidenceStateTransition +) + +func (cst EvidenceStateTransition) CursorKey() page.CursorKey { + return page.NewCursorKey(uuid.UUID(cst.ID), cst.CreatedAt) +} + +func (cst *EvidenceStateTransition) scan(r pgx.Row) error { + return r.Scan( + &cst.ID, + &cst.EvidenceID, + &cst.FromState, + &cst.ToState, + &cst.Reason, + &cst.CreatedAt, + &cst.UpdatedAt, + ) +} + +func (cst *EvidenceStateTransitions) LoadByEvidenceID( + ctx context.Context, + conn pg.Conn, + scope *Scope, + evidenceID gid.GID, + cursor *page.Cursor, +) error { + q := ` +SELECT + id, + evidence_id, + from_state, + to_state, + reason, + created_at, + updated_at +FROM + evidence_state_transitions +WHERE + %s + AND evidence_id = @evidence_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.NamedArgs{"evidence_id": evidenceID} + maps.Copy(args, scope.SQLArguments()) + + r, err := conn.Query(ctx, q, args) + if err != nil { + return err + } + defer r.Close() + + evidenceStateTransitions := EvidenceStateTransitions{} + for r.Next() { + evidenceStateTransition := &EvidenceStateTransition{} + if err := evidenceStateTransition.scan(r); err != nil { + return err + } + + evidenceStateTransitions = append(evidenceStateTransitions, evidenceStateTransition) + } + + if err := r.Err(); err != nil { + return err + } + + *cst = evidenceStateTransitions + + return nil +} diff --git a/pkg/probo/coredata/migrations/20250130T211400Z.sql b/pkg/probo/coredata/migrations/20250130T211400Z.sql new file mode 100644 index 000000000..9a3f7fa8b --- /dev/null +++ b/pkg/probo/coredata/migrations/20250130T211400Z.sql @@ -0,0 +1,14 @@ +CREATE TYPE evidence_state AS ENUM ( + 'VALID', + 'INVALID', + 'EXPIRED' +); + +CREATE TABLE evidence_state_transitions ( + id TEXT PRIMARY KEY, + from_state evidence_state, + to_state evidence_state NOT NULL, + reason TEXT, + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE +); diff --git a/pkg/probo/coredata/migrations/20250130T212000Z.sql b/pkg/probo/coredata/migrations/20250130T212000Z.sql new file mode 100644 index 000000000..82d6a033e --- /dev/null +++ b/pkg/probo/coredata/migrations/20250130T212000Z.sql @@ -0,0 +1,2 @@ +ALTER TABLE evidence_state_transitions ADD COLUMN evidence_id TEXT REFERENCES evidences(id) NOT NULL; +ALTER TABLE evidences ALTER COLUMN size TYPE BIGINT USING size::BIGINT;