From 3e4f44120dfdd7368b276c4dd6b230cfaecd82cd Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Mon, 16 Dec 2024 13:11:35 +0100 Subject: [PATCH 1/2] feat(events): Send events for workflow contract changes Signed-off-by: Javier Rodriguez --- app/controlplane/cmd/wire_gen.go | 4 +- .../pkg/auditor/events/events_test.go | 33 +++ .../testdata/{ => users}/user_logs_in.json | 0 .../testdata/{ => users}/user_signs_up.json | 0 .../workflow_attached_to_contract.json | 17 ++ ...low_attached_to_contract_by_api_token.json | 17 ++ .../workflow_contract_created.json | 15 ++ ...orkflow_contract_created_by_api_token.json | 15 ++ .../workflow_contract_deleted.json | 15 ++ ...orkflow_contract_deleted_by_api_token.json | 15 ++ .../workflow_contract_updated.json | 18 ++ ...orkflow_contract_updated_by_api_token.json | 18 ++ .../workflow_detached_from_contract.json | 17 ++ ...w_detached_from_contract_by_api_token.json | 17 ++ .../pkg/auditor/events/user_test.go | 14 +- .../pkg/auditor/events/workflowcontract.go | 183 ++++++++++++++ .../auditor/events/workflowcontract_test.go | 227 ++++++++++++++++++ .../pkg/biz/testhelpers/wire_gen.go | 4 +- app/controlplane/pkg/biz/workflow.go | 70 +++++- app/controlplane/pkg/biz/workflowcontract.go | 51 +++- 20 files changed, 725 insertions(+), 25 deletions(-) create mode 100644 app/controlplane/pkg/auditor/events/events_test.go rename app/controlplane/pkg/auditor/events/testdata/{ => users}/user_logs_in.json (100%) rename app/controlplane/pkg/auditor/events/testdata/{ => users}/user_signs_up.json (100%) create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract_by_api_token.json create mode 100644 app/controlplane/pkg/auditor/events/workflowcontract.go create mode 100644 app/controlplane/pkg/auditor/events/workflowcontract_test.go diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 6d515b753..caa9fb484 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -120,8 +120,8 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l cleanup() return nil, nil, err } - workflowContractUseCase := biz.NewWorkflowContractUseCase(workflowContractRepo, registry, logger) - workflowUseCase := biz.NewWorkflowUsecase(workflowRepo, projectsRepo, workflowContractUseCase, logger) + workflowContractUseCase := biz.NewWorkflowContractUseCase(workflowContractRepo, registry, auditorUseCase, logger) + workflowUseCase := biz.NewWorkflowUsecase(workflowRepo, projectsRepo, workflowContractUseCase, auditorUseCase, logger) projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo) v5 := serviceOpts(logger) workflowService := service.NewWorkflowService(workflowUseCase, workflowContractUseCase, projectUseCase, v5...) diff --git a/app/controlplane/pkg/auditor/events/events_test.go b/app/controlplane/pkg/auditor/events/events_test.go new file mode 100644 index 000000000..5d6867632 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/events_test.go @@ -0,0 +1,33 @@ +// +// Copyright 2024 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 ( + "flag" + "os" + "testing" +) + +var updateGolden bool + +const testEmail = "john@cyberdyne.io" + +func TestMain(m *testing.M) { + flag.BoolVar(&updateGolden, "update-golden", false, "update the expected golden files") + // Parse the flags + flag.Parse() + os.Exit(m.Run()) +} diff --git a/app/controlplane/pkg/auditor/events/testdata/user_logs_in.json b/app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json similarity index 100% rename from app/controlplane/pkg/auditor/events/testdata/user_logs_in.json rename to app/controlplane/pkg/auditor/events/testdata/users/user_logs_in.json diff --git a/app/controlplane/pkg/auditor/events/testdata/user_signs_up.json b/app/controlplane/pkg/auditor/events/testdata/users/user_signs_up.json similarity index 100% rename from app/controlplane/pkg/auditor/events/testdata/user_signs_up.json rename to app/controlplane/pkg/auditor/events/testdata/users/user_signs_up.json 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 new file mode 100644 index 000000000..f5424ec7e --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract.json @@ -0,0 +1,17 @@ +{ + "ActionType": "WorkflowContractContractAttached", + "TargetType": "WorkflowContract", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has attached the workflow test-workflow to the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow" + }, + "Digest": "sha256:1107737635a6c56e4fe4143d678783ab06c0321192f62f56ae1a2d76dea57321" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract_by_api_token.json new file mode 100644 index 000000000..4cdfeb3b9 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_attached_to_contract_by_api_token.json @@ -0,0 +1,17 @@ +{ + "ActionType": "WorkflowContractContractAttached", + "TargetType": "WorkflowContract", + "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 attached the workflow test-workflow to the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow" + }, + "Digest": "sha256:e3fe1a29d29f1427121c5023b5d643eb4f457aa7eacfc212b4cacce49c813512" +} \ No newline at end of file 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 new file mode 100644 index 000000000..6f169092c --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractCreated", + "TargetType": "WorkflowContract", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has created the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract" + }, + "Digest": "sha256:94f1ad7880f9d5bfcbe6f3a5f3d63cfa714123cd8549f9dba2ac3cc62dd5273f" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created_by_api_token.json new file mode 100644 index 000000000..df7c0419c --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_created_by_api_token.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractCreated", + "TargetType": "WorkflowContract", + "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 contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract" + }, + "Digest": "sha256:5036a6f579dbad881aeb656e4e6a402cff272e154ff5b9e7aa1631004458c07a" +} \ No newline at end of file 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 new file mode 100644 index 000000000..37b87c910 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractDeleted", + "TargetType": "WorkflowContract", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has deleted the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract" + }, + "Digest": "sha256:6ecfc282f25617197cb98e5ee9a3695fd9f12de8a1b54fc01e90ed40cf024421" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted_by_api_token.json new file mode 100644 index 000000000..d39fd6235 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_deleted_by_api_token.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractDeleted", + "TargetType": "WorkflowContract", + "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 contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract" + }, + "Digest": "sha256:84d36161f79c03eea04a85262661a87f149f2ae2812000d46f5c9a994726ef7f" +} \ No newline at end of file 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 new file mode 100644 index 000000000..59587ecbe --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated.json @@ -0,0 +1,18 @@ +{ + "ActionType": "WorkflowContractUpdated", + "TargetType": "WorkflowContract", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has updated the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "new_revision_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "new_revision": 1, + "new_description": "test description" + }, + "Digest": "sha256:db328afdbdc39f80f12f433e2740c6de395a3f84bb517a109935929a4178511d" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated_by_api_token.json new file mode 100644 index 000000000..18cad7378 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contract_updated_by_api_token.json @@ -0,0 +1,18 @@ +{ + "ActionType": "WorkflowContractUpdated", + "TargetType": "WorkflowContract", + "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 contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "new_revision_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "new_revision": 1, + "new_description": "test description" + }, + "Digest": "sha256:e289498cc45655632b4eef97fedd932a3c78323a6540809cd645e05cebcf3608" +} \ No newline at end of file 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 new file mode 100644 index 000000000..5785a4f71 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract.json @@ -0,0 +1,17 @@ +{ + "ActionType": "WorkflowContractContractDetached", + "TargetType": "WorkflowContract", + "TargetID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorType": "API_TOKEN", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "john@cyberdyne.io has detached the workflow test-workflow from the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow" + }, + "Digest": "sha256:c625fbc253b26e5f2cd3158f2b1eaf9be552a8d9d9f414addac36b1da7396661" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract_by_api_token.json new file mode 100644 index 000000000..27507a136 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_detached_from_contract_by_api_token.json @@ -0,0 +1,17 @@ +{ + "ActionType": "WorkflowContractContractDetached", + "TargetType": "WorkflowContract", + "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 detached the workflow test-workflow from the workflow contract test-contract", + "Info": { + "workflow_contract_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_contract_name": "test-contract", + "workflow_id": "1089bb36-e27b-428b-8009-d015c8737c54", + "workflow_name": "test-workflow" + }, + "Digest": "sha256:3abb7051833ec7052a7339ec632eed241c9adf216efe8f3f276d01ba29cc3732" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/user_test.go b/app/controlplane/pkg/auditor/events/user_test.go index 3d0335a0e..73a7ba049 100644 --- a/app/controlplane/pkg/auditor/events/user_test.go +++ b/app/controlplane/pkg/auditor/events/user_test.go @@ -17,7 +17,6 @@ package events_test import ( "encoding/json" - "flag" "os" "path/filepath" "testing" @@ -30,15 +29,6 @@ import ( "github.com/stretchr/testify/require" ) -var updateGolden bool - -func TestMain(m *testing.M) { - flag.BoolVar(&updateGolden, "update-golden", false, "update the expected golden files") - // Parse the flags - flag.Parse() - os.Exit(m.Run()) -} - func TestUserEvents(t *testing.T) { userUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") require.NoError(t, err) @@ -59,7 +49,7 @@ func TestUserEvents(t *testing.T) { Email: testEmail, }, }, - expected: "testdata/user_signs_up.json", + expected: "testdata/users/user_signs_up.json", }, { name: "User logs in", @@ -70,7 +60,7 @@ func TestUserEvents(t *testing.T) { }, LoggedIn: time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC), }, - expected: "testdata/user_logs_in.json", + expected: "testdata/users/user_logs_in.json", }, } diff --git a/app/controlplane/pkg/auditor/events/workflowcontract.go b/app/controlplane/pkg/auditor/events/workflowcontract.go new file mode 100644 index 000000000..120526161 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/workflowcontract.go @@ -0,0 +1,183 @@ +// +// Copyright 2024 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 = (*WorkflowContractCreated)(nil) + _ auditor.LogEntry = (*WorkflowContractUpdated)(nil) + _ auditor.LogEntry = (*WorkflowContractDeleted)(nil) + _ auditor.LogEntry = (*WorkflowContractAttached)(nil) + _ auditor.LogEntry = (*WorkflowContractDetached)(nil) +) + +const ( + WorkflowContractType auditor.TargetType = "WorkflowContract" + WorkflowContractCreatedActionType string = "WorkflowContractCreated" + WorkflowContractUpdatedActionType string = "WorkflowContractUpdated" + WorkflowContractDeletedActionType string = "WorkflowContractDeleted" + WorkflowContractContractAttachedActionType string = "WorkflowContractContractAttached" + WorkflowContractContractDetachedActionType string = "WorkflowContractContractDetached" +) + +// WorkflowContractBase is the base struct for workflow contract events +type WorkflowContractBase struct { + WorkflowContractID *uuid.UUID `json:"workflow_contract_id,omitempty"` + WorkflowContractName string `json:"workflow_contract_name,omitempty"` +} + +func (w *WorkflowContractBase) RequiresActor() bool { + return true +} + +func (w *WorkflowContractBase) TargetType() auditor.TargetType { + return WorkflowContractType +} + +func (w *WorkflowContractBase) TargetID() *uuid.UUID { + return w.WorkflowContractID +} + +func (w *WorkflowContractBase) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractID == nil || w.WorkflowContractName == "" { + return nil, errors.New("workflow contract id and name are required") + } + + return json.Marshal(&w) +} + +type WorkflowContractCreated struct { + *WorkflowContractBase +} + +func (w *WorkflowContractCreated) TargetID() *uuid.UUID { + return w.WorkflowContractBase.WorkflowContractID +} + +func (w *WorkflowContractCreated) Description() string { + wfContractName := w.WorkflowContractName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has created the workflow contract %s", wfContractName) +} + +func (w *WorkflowContractCreated) ActionType() string { + return WorkflowContractCreatedActionType +} + +func (w *WorkflowContractCreated) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractBase.WorkflowContractID == nil || w.WorkflowContractBase.WorkflowContractName == "" { + return nil, errors.New("workflow contract id and name are required") + } + + return json.Marshal(&w) +} + +type WorkflowContractUpdated struct { + *WorkflowContractBase + NewRevisionID *uuid.UUID `json:"new_revision_id,omitempty"` + NewRevision *int `json:"new_revision,omitempty"` + NewDescription *string `json:"new_description,omitempty"` +} + +func (w *WorkflowContractUpdated) ActionType() string { + return WorkflowContractUpdatedActionType +} + +func (w *WorkflowContractUpdated) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractBase.WorkflowContractID == nil || w.WorkflowContractBase.WorkflowContractName == "" { + return nil, errors.New("workflow contract id and name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowContractUpdated) Description() string { + wfContractName := w.WorkflowContractName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has updated the workflow contract %s", wfContractName) +} + +type WorkflowContractDeleted struct { + *WorkflowContractBase +} + +func (w *WorkflowContractDeleted) ActionType() string { + return WorkflowContractDeletedActionType +} + +func (w *WorkflowContractDeleted) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractBase.WorkflowContractID == nil || w.WorkflowContractBase.WorkflowContractName == "" { + return nil, errors.New("workflow contract id and name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowContractDeleted) Description() string { + wfContractName := w.WorkflowContractName + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has deleted the workflow contract %s", wfContractName) +} + +type WorkflowContractAttached struct { + *WorkflowContractBase + WorkflowID *uuid.UUID `json:"workflow_id,omitempty"` + WorkflowName string `json:"workflow_name,omitempty"` +} + +func (w *WorkflowContractAttached) ActionType() string { + return WorkflowContractContractAttachedActionType +} + +func (w *WorkflowContractAttached) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractBase.WorkflowContractID == nil || w.WorkflowContractBase.WorkflowContractName == "" || w.WorkflowID == nil || w.WorkflowName == "" { + return nil, errors.New("workflow contract id and name, workflow id and name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowContractAttached) Description() string { + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has attached the workflow %s to the workflow contract %s", w.WorkflowName, w.WorkflowContractName) +} + +type WorkflowContractDetached struct { + *WorkflowContractBase + WorkflowID *uuid.UUID `json:"workflow_id,omitempty"` + WorkflowName string `json:"workflow_name,omitempty"` +} + +func (w *WorkflowContractDetached) ActionType() string { + return WorkflowContractContractDetachedActionType +} + +func (w *WorkflowContractDetached) ActionInfo() (json.RawMessage, error) { + if w.WorkflowContractBase.WorkflowContractID == nil || w.WorkflowContractBase.WorkflowContractName == "" || w.WorkflowID == nil || w.WorkflowName == "" { + return nil, errors.New("workflow contract id and name, workflow id and name are required") + } + + return json.Marshal(&w) +} + +func (w *WorkflowContractDetached) Description() string { + return fmt.Sprintf("{{ if .ActorEmail }}{{ .ActorEmail }}{{ else }}API Token {{ .ActorID }}{{ end }} has detached the workflow %s from the workflow contract %s", w.WorkflowName, w.WorkflowContractName) +} diff --git a/app/controlplane/pkg/auditor/events/workflowcontract_test.go b/app/controlplane/pkg/auditor/events/workflowcontract_test.go new file mode 100644 index 000000000..7500d1bbc --- /dev/null +++ b/app/controlplane/pkg/auditor/events/workflowcontract_test.go @@ -0,0 +1,227 @@ +// +// Copyright 2024 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/events" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowContractEvents(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) + wfContractRevisionUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + revisionNumber := 1 + contractDescription := "test description" + wfUUID, err := uuid.Parse("1089bb36-e27b-428b-8009-d015c8737c54") + require.NoError(t, err) + wfName := "test-workflow" + + tests := []struct { + name string + event auditor.LogEntry + expected string + actor auditor.ActorType + actorID uuid.UUID + }{ + { + name: "Workflow contract created by user", + event: &events.WorkflowContractCreated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + }, + expected: "testdata/workflowcontracts/workflow_contract_created.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow contract created by API token", + event: &events.WorkflowContractCreated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + }, + expected: "testdata/workflowcontracts/workflow_contract_created_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow contract updated by user", + event: &events.WorkflowContractUpdated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + NewRevisionID: &wfContractRevisionUUID, + NewRevision: &revisionNumber, + NewDescription: &contractDescription, + }, + expected: "testdata/workflowcontracts/workflow_contract_updated.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow contract updated by API token", + event: &events.WorkflowContractUpdated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + NewRevisionID: &wfContractRevisionUUID, + NewRevision: &revisionNumber, + NewDescription: &contractDescription, + }, + expected: "testdata/workflowcontracts/workflow_contract_updated_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow contract deleted by user", + event: &events.WorkflowContractDeleted{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + }, + expected: "testdata/workflowcontracts/workflow_contract_deleted.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow contract deleted by API token", + event: &events.WorkflowContractDeleted{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + }, + expected: "testdata/workflowcontracts/workflow_contract_deleted_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow attached to contract by user", + event: &events.WorkflowContractAttached{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + }, + expected: "testdata/workflowcontracts/workflow_attached_to_contract.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow attached to contract by API token", + event: &events.WorkflowContractAttached{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + }, + expected: "testdata/workflowcontracts/workflow_attached_to_contract_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, + { + name: "Workflow detached from contract by user", + event: &events.WorkflowContractDetached{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + }, + expected: "testdata/workflowcontracts/workflow_detached_from_contract.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow detached from contract by API token", + event: &events.WorkflowContractDetached{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: uuidPtr(wfContractUUID), + WorkflowContractName: wfContractName, + }, + WorkflowID: uuidPtr(wfUUID), + WorkflowName: wfName, + }, + expected: "testdata/workflowcontracts/workflow_detached_from_contract_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.ActorTypeAPIToken, 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)) + }) + } +} diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 166cb0e1c..9ca4789b9 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -77,9 +77,9 @@ func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, r cleanup() return nil, nil, err } - workflowContractUseCase := biz.NewWorkflowContractUseCase(workflowContractRepo, registry, logger) + workflowContractUseCase := biz.NewWorkflowContractUseCase(workflowContractRepo, registry, auditorUseCase, logger) projectsRepo := data.NewProjectsRepo(dataData, logger) - workflowUseCase := biz.NewWorkflowUsecase(workflowRepo, projectsRepo, workflowContractUseCase, logger) + workflowUseCase := biz.NewWorkflowUsecase(workflowRepo, projectsRepo, workflowContractUseCase, auditorUseCase, logger) workflowRunRepo := data.NewWorkflowRunRepo(dataData, logger) workflowRunUseCase, err := biz.NewWorkflowRunUseCase(workflowRunRepo, workflowRepo, logger) if err != nil { diff --git a/app/controlplane/pkg/biz/workflow.go b/app/controlplane/pkg/biz/workflow.go index e3b768ad1..1c7dded27 100644 --- a/app/controlplane/pkg/biz/workflow.go +++ b/app/controlplane/pkg/biz/workflow.go @@ -21,6 +21,7 @@ import ( "fmt" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/go-kratos/kratos/v2/log" @@ -100,11 +101,12 @@ type WorkflowUseCase struct { wfRepo WorkflowRepo projectRepo ProjectsRepo contractUC *WorkflowContractUseCase + auditorUC *AuditorUseCase logger *log.Helper } -func NewWorkflowUsecase(wfr WorkflowRepo, projectsRepo ProjectsRepo, schemaUC *WorkflowContractUseCase, logger log.Logger) *WorkflowUseCase { - return &WorkflowUseCase{wfRepo: wfr, contractUC: schemaUC, projectRepo: projectsRepo, logger: log.NewHelper(logger)} +func NewWorkflowUsecase(wfr WorkflowRepo, projectsRepo ProjectsRepo, schemaUC *WorkflowContractUseCase, auditorUC *AuditorUseCase, logger log.Logger) *WorkflowUseCase { + return &WorkflowUseCase{wfRepo: wfr, contractUC: schemaUC, projectRepo: projectsRepo, auditorUC: auditorUC, logger: log.NewHelper(logger)} } func (uc *WorkflowUseCase) Create(ctx context.Context, opts *WorkflowCreateOpts) (*Workflow, error) { @@ -145,6 +147,21 @@ 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 { + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractAttached{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: &contract.ID, + WorkflowContractName: contract.Name, + }, + WorkflowID: &wf.ID, + WorkflowName: wf.Name, + }, &orgUUID) + } + return wf, nil } @@ -164,17 +181,19 @@ func (uc *WorkflowUseCase) Update(ctx context.Context, orgID, workflowID string, } // make sure that the workflow is for the provided org - if wf, err := uc.wfRepo.GetOrgScoped(ctx, orgUUID, workflowUUID); err != nil { + var preUpdateWorkflow *Workflow + if preUpdateWorkflow, err = uc.wfRepo.GetOrgScoped(ctx, orgUUID, workflowUUID); err != nil { return nil, err - } else if wf == nil { + } else if preUpdateWorkflow == nil { return nil, NewErrNotFound("workflow in organization") } // Double check that the contract exists + var wfContract *WorkflowContract if opts.ContractID != nil { - if c, err := uc.contractUC.FindByIDInOrg(ctx, orgID, *opts.ContractID); err != nil { + if wfContract, err = uc.contractUC.FindByIDInOrg(ctx, orgID, *opts.ContractID); err != nil { return nil, err - } else if c == nil { + } else if wfContract == nil { return nil, NewErrNotFound("contract") } } @@ -190,9 +209,48 @@ 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 + if opts.ContractID != nil { + if preUpdateWorkflow.ContractID != uuid.Nil { + uc.dispatchContractEvent(ctx, orgID, preUpdateWorkflow.ContractID, preUpdateWorkflow.ContractName, wf.ID, wf.Name, false) + } + + if wfContract != nil { + uc.dispatchContractEvent(ctx, orgID, wfContract.ID, wfContract.Name, wf.ID, wf.Name, true) + } + } + return wf, err } +// 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 + } + contractBase := &events.WorkflowContractBase{ + WorkflowContractID: &contractID, + WorkflowContractName: contractName, + } + + if attached { + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractAttached{ + WorkflowContractBase: contractBase, + WorkflowID: &wfID, + WorkflowName: wfName, + }, &orgUUID) + } else { + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractDetached{ + WorkflowContractBase: contractBase, + WorkflowID: &wfID, + WorkflowName: wfName, + }, &orgUUID) + } +} + func (uc *WorkflowUseCase) findOrCreateContract(ctx context.Context, orgID, name string) (*WorkflowContract, error) { e, _ := uc.contractUC.FindByNameInOrg(ctx, orgID, name) if e != nil { diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index c90ce79e5..4476c3947 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -21,6 +21,8 @@ import ( "fmt" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + schemav1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" @@ -103,10 +105,11 @@ type WorkflowContractUseCase struct { repo WorkflowContractRepo logger *log.Helper policyRegistry *policies.Registry + auditorUC *AuditorUseCase } -func NewWorkflowContractUseCase(repo WorkflowContractRepo, policyRegistry *policies.Registry, logger log.Logger) *WorkflowContractUseCase { - return &WorkflowContractUseCase{repo: repo, policyRegistry: policyRegistry, logger: log.NewHelper(logger)} +func NewWorkflowContractUseCase(repo WorkflowContractRepo, policyRegistry *policies.Registry, auditorUC *AuditorUseCase, logger log.Logger) *WorkflowContractUseCase { + return &WorkflowContractUseCase{repo: repo, policyRegistry: policyRegistry, auditorUC: auditorUC, logger: log.NewHelper(logger)} } func (uc *WorkflowContractUseCase) List(ctx context.Context, orgID string) ([]*WorkflowContract, error) { @@ -203,6 +206,14 @@ func (uc *WorkflowContractUseCase) Create(ctx context.Context, opts *WorkflowCon return nil, fmt.Errorf("failed to create contract: %w", err) } + // Dispatch the event + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractCreated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: &c.ID, + WorkflowContractName: c.Name, + }, + }, &orgUUID) + return c, nil } @@ -289,6 +300,11 @@ func (uc *WorkflowContractUseCase) Update(ctx context.Context, orgID, name strin contract = c } + wfContractPreUpdate, err := uc.repo.FindByNameInOrg(ctx, orgUUID, name) + if err != nil { + return nil, fmt.Errorf("failed to find contract %s in org %s", name, orgUUID) + } + args := &ContractUpdateOpts{Description: opts.Description, Contract: contract} c, err := uc.repo.Update(ctx, orgUUID, name, args) if err != nil { @@ -297,6 +313,23 @@ func (uc *WorkflowContractUseCase) Update(ctx context.Context, orgID, name strin return nil, NewErrNotFound("contract") } + // Dispatch the event + eventPayload := &events.WorkflowContractUpdated{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: &c.Contract.ID, + WorkflowContractName: c.Contract.Name, + }, + NewDescription: opts.Description, + } + + // Check if the revisions have changed + if wfContractPreUpdate.LatestRevision != c.Version.Revision { + eventPayload.NewRevision = &c.Version.Revision + eventPayload.NewRevisionID = &c.Version.ID + } + + uc.auditorUC.Dispatch(ctx, eventPayload, &orgUUID) + return c, nil } @@ -415,7 +448,19 @@ func (uc *WorkflowContractUseCase) Delete(ctx context.Context, orgID, contractID } // Check that the workflow to delete belongs to the provided organization - return uc.repo.SoftDelete(ctx, contractUUID) + if err := uc.repo.SoftDelete(ctx, contractUUID); err != nil { + return fmt.Errorf("failed to delete contract: %w", err) + } + + // Dispatch the event + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractDeleted{ + WorkflowContractBase: &events.WorkflowContractBase{ + WorkflowContractID: &contract.ID, + WorkflowContractName: contract.Name, + }, + }, &orgUUID) + + return nil } type RemotePolicy struct { From fcc964fa9d7de755d7e5cea108a73c437f573da0 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Mon, 16 Dec 2024 13:27:05 +0100 Subject: [PATCH 2/2] fix err wrapper Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/workflowcontract.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index 4476c3947..9b043be18 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -302,7 +302,7 @@ func (uc *WorkflowContractUseCase) Update(ctx context.Context, orgID, name strin wfContractPreUpdate, err := uc.repo.FindByNameInOrg(ctx, orgUUID, name) if err != nil { - return nil, fmt.Errorf("failed to find contract %s in org %s", name, orgUUID) + return nil, fmt.Errorf("failed to find contract %s in org %s: %w", name, orgUUID, err) } args := &ContractUpdateOpts{Description: opts.Description, Contract: contract}