From 55e26373944e21b9093187bb8975d667cab210ef Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 11:49:36 +0200 Subject: [PATCH 1/3] chore(audit): Instrument Workflows with event auditor Signed-off-by: Javier Rodriguez --- app/controlplane/configs/config.devel.yaml | 2 +- .../events/testdata/users/user_logs_in.json | 2 +- .../workflow_attached_to_contract.json | 2 +- .../workflow_contract_created.json | 2 +- .../workflow_contract_deleted.json | 2 +- .../workflow_contract_updated.json | 2 +- .../workflow_detached_from_contract.json | 2 +- .../testdata/workflows/workflow_created.json | 20 ++ .../workflow_created_by_api_token.json | 20 ++ .../testdata/workflows/workflow_deleted.json | 16 ++ .../workflow_deleted_by_api_token.json | 16 ++ .../testdata/workflows/workflow_updated.json | 19 ++ .../workflow_updated_by_api_token.json | 19 ++ ...rkflow_updated_with_workflow_contract.json | 21 ++ ...d_with_workflow_contract_by_api_token.json | 21 ++ .../pkg/auditor/events/workflow.go | 144 +++++++++++ .../pkg/auditor/events/workflow_test.go | 227 ++++++++++++++++++ .../auditor/events/workflowcontract_test.go | 2 +- app/controlplane/pkg/biz/workflow.go | 96 ++++++-- 19 files changed, 606 insertions(+), 29 deletions(-) create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/workflow.go create mode 100644 app/controlplane/pkg/auditor/events/workflow_test.go diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index b557cf6be..550bae48a 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -17,7 +17,7 @@ server: # private_key: "../../devel/devkeys/selfsigned/controlplane.key" # nats_server: -# uri: nats://0.0.0.0:4222 +# uri: nats://0.0.0.0:4222 certificate_authorities: - issuer: true diff --git a/app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json b/app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json index 913c8723b..77f486cbb 100644 --- a/app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json +++ b/app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json @@ -13,4 +13,4 @@ "LoggedIn": "2024-01-01T00:00:00Z" }, "Digest": "sha256:9a2d11d9423700c50b7a9b1d6e40b3327b3fa3afe4e33f72d1c8b20d733717bd" -} +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json index f5424ec7e..7ba3cda87 100644 --- a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json @@ -2,7 +2,7 @@ "ActionType": "WorkflowContractContractAttached", "TargetType": "WorkflowContract", "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", - "ActorType": "API_TOKEN", + "ActorType": "USER", "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", "ActorEmail": "john@cyberdyne.io", "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json index 6f169092c..5828032f4 100644 --- a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json @@ -2,7 +2,7 @@ "ActionType": "WorkflowContractCreated", "TargetType": "WorkflowContract", "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", - "ActorType": "API_TOKEN", + "ActorType": "USER", "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", "ActorEmail": "john@cyberdyne.io", "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json index 37b87c910..84b7a7d64 100644 --- a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json @@ -2,7 +2,7 @@ "ActionType": "WorkflowContractDeleted", "TargetType": "WorkflowContract", "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", - "ActorType": "API_TOKEN", + "ActorType": "USER", "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", "ActorEmail": "john@cyberdyne.io", "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json index 59587ecbe..a9811a709 100644 --- a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json @@ -2,7 +2,7 @@ "ActionType": "WorkflowContractUpdated", "TargetType": "WorkflowContract", "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", - "ActorType": "API_TOKEN", + "ActorType": "USER", "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", "ActorEmail": "john@cyberdyne.io", "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json index 5785a4f71..5d6ce4c59 100644 --- a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json @@ -2,7 +2,7 @@ "ActionType": "WorkflowContractContractDetached", "TargetType": "WorkflowContract", "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", - "ActorType": "API_TOKEN", + "ActorType": "USER", "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", "ActorEmail": "john@cyberdyne.io", "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created.json new file mode 100644 index 000000000..016e03ec1 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created.json @@ -0,0 +1,20 @@ +{ + "ActionType": "WorkflowCreated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has created the workflow test-workflow on project test-project with the contract test-contract", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow", + "project_name": "test-project", + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "description": "test description", + "team": "test-team" + }, + "Digest": "sha256:b3c8f05e7def2e9de7c4f474175b852b1c29a3e7f9dd44f994201fbfd17de490" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created_by_api_token.json new file mode 100644 index 000000000..5df692dd9 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_created_by_api_token.json @@ -0,0 +1,20 @@ +{ + "ActionType": "WorkflowCreated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorEmail": "", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "API Token 2089bb36-e27b-428b-8009-d015c8737c55 has created the workflow test-workflow on project test-project with the contract test-contract", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow", + "project_name": "test-project", + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "description": "test description", + "team": "test-team" + }, + "Digest": "sha256:c2a947583e61b2941d4a1433302129e2f8dad9f57eb8a105c324842ee81aac00" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted.json new file mode 100644 index 000000000..64c0387f6 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted.json @@ -0,0 +1,16 @@ +{ + "ActionType": "WorkflowDeleted", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has deleted the workflow test-contract", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project" + }, + "Digest": "sha256:f81f68c312421ad0cc06c86e73c732e889e86028f53f06c274b358bed19d7e8e" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted_by_api_token.json new file mode 100644 index 000000000..8a231d4dd --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_deleted_by_api_token.json @@ -0,0 +1,16 @@ +{ + "ActionType": "WorkflowDeleted", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorEmail": "", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "API Token 2089bb36-e27b-428b-8009-d015c8737c55 has deleted the workflow test-contract", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project" + }, + "Digest": "sha256:e461b550b100a5891dbcf62db574afe9a7b4562cc062725c2c4da90b56674ac9" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated.json new file mode 100644 index 000000000..c632dafbc --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated.json @@ -0,0 +1,19 @@ +{ + "ActionType": "WorkflowUpdated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has updated the workflow test-contract on project test-project", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project", + "new_description": "test description", + "new_team": "test-team", + "new_public": true + }, + "Digest": "sha256:96de55711fafc04c5f2e7a0b6d6a40df835bde6c9dbe148b00f565b3915c2229" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_by_api_token.json new file mode 100644 index 000000000..a65dec314 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_by_api_token.json @@ -0,0 +1,19 @@ +{ + "ActionType": "WorkflowUpdated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorEmail": "", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "API Token 2089bb36-e27b-428b-8009-d015c8737c55 has updated the workflow test-contract on project test-project", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project", + "new_description": "test description", + "new_team": "test-team", + "new_public": true + }, + "Digest": "sha256:fc33dee1b1a043d847a6c4c0d945f75d18508675569f43915a7149e9e756ad65" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract.json new file mode 100644 index 000000000..b48cfa745 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract.json @@ -0,0 +1,21 @@ +{ + "ActionType": "WorkflowUpdated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has updated the workflow test-contract on project test-project", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project", + "new_description": "test description", + "new_team": "test-team", + "new_public": true, + "new_workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "new_workflow_contract_name": "test-contract" + }, + "Digest": "sha256:9e28ae18fa9d44faa4a6d072f928bd9b70bda9aded9abdcedc776819f4e638e7" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract_by_api_token.json new file mode 100644 index 000000000..4aec0af2e --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflows/workflow_updated_with_workflow_contract_by_api_token.json @@ -0,0 +1,21 @@ +{ + "ActionType": "WorkflowUpdated", + "TargetType": "Workflow", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "API Token 1089bb36-e27b-428b-8009-d015c8737c54 has updated the workflow test-contract on project test-project", + "Info": { + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-contract", + "project_name": "test-project", + "new_description": "test description", + "new_team": "test-team", + "new_public": true, + "new_workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "new_workflow_contract_name": "test-contract" + }, + "Digest": "sha256:9e28ae18fa9d44faa4a6d072f928bd9b70bda9aded9abdcedc776819f4e638e7" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/workflow.go b/app/controlplane/pkg/auditor/events/workflow.go new file mode 100644 index 000000000..14497d8ee --- /dev/null +++ b/app/controlplane/pkg/auditor/events/workflow.go @@ -0,0 +1,144 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package events + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + + "github.com/google/uuid" +) + +var ( + _ auditor.LogEntry = (*WorkflowCreated)(nil) +) + +const ( + WorkflowType auditor.TargetType = "Workflow" + WorkflowCreatedActionType string = "WorkflowCreated" + WorkflowUpdatedActionType string = "WorkflowUpdated" + WorkflowDeletedActionType string = "WorkflowDeleted" +) + +// WorkflowBase is the base struct for workflow events +type WorkflowBase struct { + WorkflowID *uuid.UUID `json:"workflow_id,omitempty"` + WorkflowName string `json:"workflow_name,omitempty"` + ProjectName string `json:"project_name,omitempty"` +} + +func (w *WorkflowBase) RequiresActor() bool { + return true +} + +func (w *WorkflowBase) TargetType() auditor.TargetType { + return WorkflowType +} + +func (w *WorkflowBase) TargetID() *uuid.UUID { + return w.WorkflowID +} + +func (w *WorkflowBase) ActionInfo() (json.RawMessage, error) { + if w.WorkflowID == nil || w.WorkflowName == "" || w.ProjectName == "" { + return nil, errors.New("workflow id, name and project name are required") + } + + return json.Marshal(&w) +} + +type WorkflowCreated struct { + *WorkflowBase + WorkflowContractID *uuid.UUID `json:"workflow_contract_id,omitempty"` + WorkflowContractName string `json:"workflow_contract_name,omitempty"` + WorkflowDescription *string `json:"description,omitempty"` + Team *string `json:"team,omitempty"` + Public bool `json:"public,omitempty"` +} + +func (w *WorkflowCreated) TargetID() *uuid.UUID { + return w.WorkflowBase.WorkflowID +} + +func (w *WorkflowCreated) Description() string { + workflowName := w.WorkflowBase.WorkflowName + projectName := w.WorkflowBase.ProjectName + workflowContractName := w.WorkflowContractName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has created the workflow %s on project %s with the contract %s", workflowName, projectName, workflowContractName) +} + +func (w *WorkflowCreated) ActionType() string { + return WorkflowCreatedActionType +} + +func (w *WorkflowCreated) ActionInfo() (json.RawMessage, error) { + if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" || w.WorkflowContractID == nil || w.WorkflowContractName == "" { + return nil, errors.New("workflow id, name, project name, contract id and contract name required") + } + + return json.Marshal(&w) +} + +type WorkflowUpdated struct { + *WorkflowBase + NewDescription *string `json:"new_description,omitempty"` + NewTeam *string `json:"new_team,omitempty"` + NewPublic *bool `json:"new_public,omitempty"` + NewWorkflowContractID *uuid.UUID `json:"new_workflow_contract_id,omitempty"` + NewWorkflowContractName *string `json:"new_workflow_contract_name,omitempty"` +} + +func (w *WorkflowUpdated) ActionType() string { + return WorkflowUpdatedActionType +} + +func (w *WorkflowUpdated) ActionInfo() (json.RawMessage, error) { + if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" { + return nil, errors.New("workflow id, name and project name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowUpdated) Description() string { + workflowName := w.WorkflowBase.WorkflowName + projectName := w.WorkflowBase.ProjectName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has updated the workflow %s on project %s", workflowName, projectName) +} + +type WorkflowDeleted struct { + *WorkflowBase +} + +func (w *WorkflowDeleted) ActionType() string { + return WorkflowDeletedActionType +} + +func (w *WorkflowDeleted) ActionInfo() (json.RawMessage, error) { + if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" { + return nil, errors.New("workflow id, name and project name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowDeleted) Description() string { + wfName := w.WorkflowBase.WorkflowName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has deleted the workflow %s", wfName) +} diff --git a/app/controlplane/pkg/auditor/events/workflow_test.go b/app/controlplane/pkg/auditor/events/workflow_test.go new file mode 100644 index 000000000..941cbbe3f --- /dev/null +++ b/app/controlplane/pkg/auditor/events/workflow_test.go @@ -0,0 +1,227 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package events_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + "github.com/stretchr/testify/require" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestWorkflowEvents(t *testing.T) { + userUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + apiTokenUUID, err := uuid.Parse("2089bb36-e27b-428b-8009-d015c8737c55") + require.NoError(t, err) + orgUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + wfContractName := "test-contract" + wfContractUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + wfDescription := "test description" + wfUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + wfName := "test-workflow" + projectName := "test-project" + newTeam := "test-team" + description := "test description" + + tests := []struct { + name string + event auditor.LogEntry + expected string + actor auditor.ActorType + actorID uuid.UUID + }{ + { + name: "Workflow created by user", + event: &events.WorkflowCreated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + ProjectName: projectName, + }, + WorkflowContractID: &wfContractUUID, + WorkflowContractName: wfContractName, + WorkflowDescription: &description, + Team: &newTeam, + Public: false, + }, + expected: "testdata/workflows/workflow_created.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow created by API token", + event: &events.WorkflowCreated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + ProjectName: projectName, + }, + WorkflowContractID: &wfContractUUID, + WorkflowContractName: wfContractName, + WorkflowDescription: &description, + Team: &newTeam, + Public: false, + }, + expected: "testdata/workflows/workflow_created_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow updated by user", + event: &events.WorkflowUpdated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + NewDescription: &wfDescription, + NewTeam: &newTeam, + NewPublic: boolPtr(true), + }, + expected: "testdata/workflows/workflow_updated.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow updated by API token", + event: &events.WorkflowUpdated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + NewDescription: &wfDescription, + NewTeam: &newTeam, + NewPublic: boolPtr(true), + }, + expected: "testdata/workflows/workflow_updated_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow updated with workflow contract by user", + event: &events.WorkflowUpdated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + NewDescription: &wfDescription, + NewTeam: &newTeam, + NewPublic: boolPtr(true), + NewWorkflowContractID: &wfContractUUID, + NewWorkflowContractName: &wfContractName, + }, + expected: "testdata/workflows/workflow_updated_with_workflow_contract.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow updated with workflow contract by API token", + event: &events.WorkflowUpdated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + NewDescription: &wfDescription, + NewTeam: &newTeam, + NewPublic: boolPtr(true), + NewWorkflowContractID: &wfContractUUID, + NewWorkflowContractName: &wfContractName, + }, + expected: "testdata/workflows/workflow_updated_with_workflow_contract_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: userUUID, + }, + { + name: "Workflow deleted by user", + event: &events.WorkflowDeleted{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + }, + expected: "testdata/workflows/workflow_deleted.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow deleted by API token", + event: &events.WorkflowDeleted{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: uuidPtr(wfContractUUID), + WorkflowName: wfContractName, + ProjectName: projectName, + }, + }, + expected: "testdata/workflows/workflow_deleted_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []auditor.GeneratorOption{ + auditor.WithOrgID(orgUUID), + } + if tt.actor == auditor.ActorTypeAPIToken { + opts = append(opts, auditor.WithActor(auditor.ActorTypeAPIToken, tt.actorID, "")) + } else { + opts = append(opts, auditor.WithActor(auditor.ActorTypeUser, tt.actorID, testEmail)) + } + + eventPayload, err := auditor.GenerateAuditEvent(tt.event, opts...) + require.NoError(t, err) + + want, err := json.MarshalIndent(eventPayload.Data, "", " ") + require.NoError(t, err) + + if updateGolden { + err := os.WriteFile(filepath.Clean(tt.expected), want, 0600) + require.NoError(t, err) + } + + gotRaw, err := os.ReadFile(filepath.Clean(tt.expected)) + require.NoError(t, err) + + var gotPayload auditor.AuditEventPayload + err = json.Unmarshal(gotRaw, &gotPayload) + require.NoError(t, err) + got, err := json.MarshalIndent(gotPayload, "", " ") + require.NoError(t, err) + + assert.Equal(t, string(want), string(got)) + }) + } +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/app/controlplane/pkg/auditor/events/workflowcontract_test.go b/app/controlplane/pkg/auditor/events/workflowcontract_test.go index 7500d1bbc..39f235e47 100644 --- a/app/controlplane/pkg/auditor/events/workflowcontract_test.go +++ b/app/controlplane/pkg/auditor/events/workflowcontract_test.go @@ -198,7 +198,7 @@ func TestWorkflowContractEvents(t *testing.T) { if tt.actor == auditor.ActorTypeAPIToken { opts = append(opts, auditor.WithActor(auditor.ActorTypeAPIToken, tt.actorID, "")) } else { - opts = append(opts, auditor.WithActor(auditor.ActorTypeAPIToken, tt.actorID, testEmail)) + opts = append(opts, auditor.WithActor(auditor.ActorTypeUser, tt.actorID, testEmail)) } eventPayload, err := auditor.GenerateAuditEvent(tt.event, opts...) diff --git a/app/controlplane/pkg/biz/workflow.go b/app/controlplane/pkg/biz/workflow.go index d59a74784..a15979f14 100644 --- a/app/controlplane/pkg/biz/workflow.go +++ b/app/controlplane/pkg/biz/workflow.go @@ -151,11 +151,25 @@ func (uc *WorkflowUseCase) Create(ctx context.Context, opts *WorkflowCreateOpts) return nil, fmt.Errorf("failed to create workflow: %w", err) } - // Dispatch events to the audit log regarding the contract orgUUID, err := uuid.Parse(opts.OrgID) if err != nil { uc.logger.Warn("failed to parse org id", "err", err) } else { + // Dispatch events to the audit log regarding the workflow + uc.auditorUC.Dispatch(ctx, &events.WorkflowCreated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: &wf.ID, + WorkflowName: wf.Name, + ProjectName: opts.Project, + }, + WorkflowContractID: &contract.ID, + WorkflowContractName: contract.Name, + WorkflowDescription: &opts.Description, + Team: &opts.Team, + Public: opts.Public, + }, &orgUUID) + + // Dispatch events to the audit log regarding the contract uc.auditorUC.Dispatch(ctx, &events.WorkflowContractAttached{ WorkflowContractBase: &events.WorkflowContractBase{ WorkflowContractID: &contract.ID, @@ -213,28 +227,55 @@ func (uc *WorkflowUseCase) Update(ctx context.Context, orgID, workflowID string, return nil, NewErrNotFound("workflow") } - // Dispatch events to the audit log regarding the contract if it has changed - // or if it has been attached or detached + // Dispatch events to the audit log regarding the workflow + uc.handleWorkflowUpdateEvents(ctx, wf, preUpdateWorkflow, opts, wfContract, orgUUID) + + return wf, err +} + +// handleWorkflowUpdateEvents dispatches events to the audit log regarding the workflow +func (uc *WorkflowUseCase) handleWorkflowUpdateEvents(ctx context.Context, wf *Workflow, preUpdateWorkflow *Workflow, opts *WorkflowUpdateOpts, newWfContract *WorkflowContract, orgUUID uuid.UUID) { + uc.dispatchWorkflowUpdatedEvent(ctx, wf, opts, newWfContract, orgUUID) + if opts.ContractID != nil { - if preUpdateWorkflow.ContractID != uuid.Nil { - uc.dispatchContractEvent(ctx, orgID, preUpdateWorkflow.ContractID, preUpdateWorkflow.ContractName, wf.ID, wf.Name, false) - } + uc.handleContractChangeEvents(ctx, preUpdateWorkflow, wf, newWfContract, orgUUID) + } +} - if wfContract != nil { - uc.dispatchContractEvent(ctx, orgID, wfContract.ID, wfContract.Name, wf.ID, wf.Name, true) - } +// dispatchWorkflowUpdatedEvent dispatches events to the audit log regarding the workflow +func (uc *WorkflowUseCase) dispatchWorkflowUpdatedEvent(ctx context.Context, wf *Workflow, opts *WorkflowUpdateOpts, newWfContract *WorkflowContract, orgUUID uuid.UUID) { + baseOptions := &events.WorkflowUpdated{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: &wf.ID, + WorkflowName: wf.Name, + ProjectName: wf.Project, + }, + NewDescription: opts.Description, + NewTeam: opts.Team, + NewPublic: opts.Public, } - return wf, err + if opts.ContractID != nil && newWfContract != nil { + baseOptions.NewWorkflowContractName = &newWfContract.Name + baseOptions.NewWorkflowContractID = &newWfContract.ID + } + + uc.auditorUC.Dispatch(ctx, baseOptions, &orgUUID) } -// dispatchContractEvent dispatches events to the audit log regarding the contract -func (uc *WorkflowUseCase) dispatchContractEvent(ctx context.Context, orgID string, contractID uuid.UUID, contractName string, wfID uuid.UUID, wfName string, attached bool) { - orgUUID, err := uuid.Parse(orgID) - if err != nil { - uc.logger.Warn("failed to parse org id", "err", err) - return +// handleContractChangeEvents dispatches events to the audit log regarding the contract +func (uc *WorkflowUseCase) handleContractChangeEvents(ctx context.Context, preUpdateWorkflow *Workflow, wf *Workflow, newWfContract *WorkflowContract, orgUUID uuid.UUID) { + // Only process contract events if the contract ID has actually changed + if newWfContract != nil && preUpdateWorkflow.ContractID != newWfContract.ID { + if preUpdateWorkflow.ContractID != uuid.Nil { + uc.dispatchContractEvent(ctx, orgUUID, preUpdateWorkflow.ContractID, preUpdateWorkflow.ContractName, wf.ID, wf.Name, false) + } + uc.dispatchContractEvent(ctx, orgUUID, newWfContract.ID, newWfContract.Name, wf.ID, wf.Name, true) } +} + +// dispatchContractEvent dispatches events to the audit log regarding the contract +func (uc *WorkflowUseCase) dispatchContractEvent(ctx context.Context, orgID uuid.UUID, contractID uuid.UUID, contractName string, wfID uuid.UUID, wfName string, attached bool) { contractBase := &events.WorkflowContractBase{ WorkflowContractID: &contractID, WorkflowContractName: contractName, @@ -245,13 +286,13 @@ func (uc *WorkflowUseCase) dispatchContractEvent(ctx context.Context, orgID stri WorkflowContractBase: contractBase, WorkflowID: &wfID, WorkflowName: wfName, - }, &orgUUID) + }, &orgID) } else { uc.auditorUC.Dispatch(ctx, &events.WorkflowContractDetached{ WorkflowContractBase: contractBase, WorkflowID: &wfID, WorkflowName: wfName, - }, &orgUUID) + }, &orgID) } } @@ -351,11 +392,24 @@ func (uc *WorkflowUseCase) Delete(ctx context.Context, orgID, workflowID string) } // Check that the workflow to delete belongs to the provided organization - if wf, err := uc.wfRepo.GetOrgScoped(ctx, orgUUID, workflowUUID); err != nil { - return err + wf, err := uc.wfRepo.GetOrgScoped(ctx, orgUUID, workflowUUID) + if err != nil { + return fmt.Errorf("failed to get workflow: %w", err) } else if wf == nil { return NewErrNotFound("organization") } - return uc.wfRepo.SoftDelete(ctx, workflowUUID) + if err := uc.wfRepo.SoftDelete(ctx, workflowUUID); err != nil { + return fmt.Errorf("failed to soft delete workflow: %w", err) + } + + uc.auditorUC.Dispatch(ctx, &events.WorkflowDeleted{ + WorkflowBase: &events.WorkflowBase{ + WorkflowID: &workflowUUID, + WorkflowName: wf.Name, + ProjectName: wf.Project, + }, + }, &orgUUID) + + return nil } From 1d8995faeec06e55534bcfd1d7dfe56d956b5c22 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 12:47:59 +0200 Subject: [PATCH 2/3] fix linter Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/auditor/events/workflow.go | 6 +++--- app/controlplane/pkg/auditor/events/workflow_test.go | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/controlplane/pkg/auditor/events/workflow.go b/app/controlplane/pkg/auditor/events/workflow.go index 14497d8ee..edd18f05e 100644 --- a/app/controlplane/pkg/auditor/events/workflow.go +++ b/app/controlplane/pkg/auditor/events/workflow.go @@ -73,12 +73,12 @@ type WorkflowCreated struct { } func (w *WorkflowCreated) TargetID() *uuid.UUID { - return w.WorkflowBase.WorkflowID + return w.WorkflowID } func (w *WorkflowCreated) Description() string { - workflowName := w.WorkflowBase.WorkflowName - projectName := w.WorkflowBase.ProjectName + workflowName := w.WorkflowName + projectName := w.ProjectName workflowContractName := w.WorkflowContractName return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has created the workflow %s on project %s with the contract %s", workflowName, projectName, workflowContractName) } diff --git a/app/controlplane/pkg/auditor/events/workflow_test.go b/app/controlplane/pkg/auditor/events/workflow_test.go index 941cbbe3f..57fa3e193 100644 --- a/app/controlplane/pkg/auditor/events/workflow_test.go +++ b/app/controlplane/pkg/auditor/events/workflow_test.go @@ -45,7 +45,6 @@ func TestWorkflowEvents(t *testing.T) { wfName := "test-workflow" projectName := "test-project" newTeam := "test-team" - description := "test description" tests := []struct { name string @@ -64,7 +63,7 @@ func TestWorkflowEvents(t *testing.T) { }, WorkflowContractID: &wfContractUUID, WorkflowContractName: wfContractName, - WorkflowDescription: &description, + WorkflowDescription: &wfDescription, Team: &newTeam, Public: false, }, @@ -82,7 +81,7 @@ func TestWorkflowEvents(t *testing.T) { }, WorkflowContractID: &wfContractUUID, WorkflowContractName: wfContractName, - WorkflowDescription: &description, + WorkflowDescription: &wfDescription, Team: &newTeam, Public: false, }, From cd8ea78275fe7c8abc622caff9902aa1232b4f32 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 12:56:19 +0200 Subject: [PATCH 3/3] fix linter Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/auditor/events/workflow.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controlplane/pkg/auditor/events/workflow.go b/app/controlplane/pkg/auditor/events/workflow.go index edd18f05e..e2c5db939 100644 --- a/app/controlplane/pkg/auditor/events/workflow.go +++ b/app/controlplane/pkg/auditor/events/workflow.go @@ -88,7 +88,7 @@ func (w *WorkflowCreated) ActionType() string { } func (w *WorkflowCreated) ActionInfo() (json.RawMessage, error) { - if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" || w.WorkflowContractID == nil || w.WorkflowContractName == "" { + if w.WorkflowID == nil || w.WorkflowName == "" || w.ProjectName == "" || w.WorkflowContractID == nil || w.WorkflowContractName == "" { return nil, errors.New("workflow id, name, project name, contract id and contract name required") } @@ -109,7 +109,7 @@ func (w *WorkflowUpdated) ActionType() string { } func (w *WorkflowUpdated) ActionInfo() (json.RawMessage, error) { - if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" { + if w.WorkflowID == nil || w.WorkflowName == "" || w.ProjectName == "" { return nil, errors.New("workflow id, name and project name are required") } @@ -117,8 +117,8 @@ func (w *WorkflowUpdated) ActionInfo() (json.RawMessage, error) { } func (w *WorkflowUpdated) Description() string { - workflowName := w.WorkflowBase.WorkflowName - projectName := w.WorkflowBase.ProjectName + workflowName := w.WorkflowName + projectName := w.ProjectName return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has updated the workflow %s on project %s", workflowName, projectName) } @@ -131,7 +131,7 @@ func (w *WorkflowDeleted) ActionType() string { } func (w *WorkflowDeleted) ActionInfo() (json.RawMessage, error) { - if w.WorkflowBase.WorkflowID == nil || w.WorkflowBase.WorkflowName == "" || w.WorkflowBase.ProjectName == "" { + if w.WorkflowID == nil || w.WorkflowName == "" || w.ProjectName == "" { return nil, errors.New("workflow id, name and project name are required") } @@ -139,6 +139,6 @@ func (w *WorkflowDeleted) ActionInfo() (json.RawMessage, error) { } func (w *WorkflowDeleted) Description() string { - wfName := w.WorkflowBase.WorkflowName + wfName := w.WorkflowName return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has deleted the workflow %s", wfName) }