From b2bc53784f85dd83f2581280738b9dff9c2df447 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 12:44:03 +0200 Subject: [PATCH 1/3] chore(audit): Instrument API Tokens with event auditor Signed-off-by: Javier Rodriguez --- app/controlplane/cmd/wire_gen.go | 2 +- .../pkg/auditor/events/apitoken.go | 113 ++++++++++++++ .../pkg/auditor/events/apitoken_test.go | 139 ++++++++++++++++++ .../testdata/apitokens/api_token_created.json | 15 ++ .../api_token_created_with_description.json | 16 ++ ...pi_token_created_with_expiration_date.json | 17 +++ .../testdata/apitokens/api_token_revoked.json | 15 ++ app/controlplane/pkg/biz/apitoken.go | 37 ++++- app/controlplane/pkg/biz/auditor.go | 2 +- .../pkg/biz/testhelpers/wire_gen.go | 2 +- 10 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 app/controlplane/pkg/auditor/events/apitoken.go create mode 100644 app/controlplane/pkg/auditor/events/apitoken_test.go create mode 100644 app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_description.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_expiration_date.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_revoked.json diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 8b8703f93..be72e4d5e 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -106,7 +106,7 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l cleanup() return nil, nil, err } - apiTokenUseCase, err := biz.NewAPITokenUseCase(apiTokenRepo, auth, enforcer, organizationUseCase, logger) + apiTokenUseCase, err := biz.NewAPITokenUseCase(apiTokenRepo, auth, enforcer, organizationUseCase, auditorUseCase, logger) if err != nil { cleanup() return nil, nil, err diff --git a/app/controlplane/pkg/auditor/events/apitoken.go b/app/controlplane/pkg/auditor/events/apitoken.go new file mode 100644 index 000000000..ce766a079 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/apitoken.go @@ -0,0 +1,113 @@ +// +// 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" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + + "github.com/google/uuid" +) + +var ( + _ auditor.LogEntry = (*APITokenCreated)(nil) + _ auditor.LogEntry = (*APITokenRevoked)(nil) +) + +const ( + APITokenType auditor.TargetType = "APIToken" + APITokenCreatedActionType string = "APITokenCreated" + APITokenRevokedActionType string = "APITokenRevoked" +) + +type APITokenBase struct { + APITokenID *uuid.UUID `json:"api_token_id,omitempty"` + APITokenName string `json:"api_token_name,omitempty"` +} + +func (a *APITokenBase) RequiresActor() bool { + return true +} + +func (a *APITokenBase) TargetType() auditor.TargetType { + return APITokenType +} + +func (a *APITokenBase) TargetID() *uuid.UUID { + return a.APITokenID +} + +func (a *APITokenBase) ActionInfo() (json.RawMessage, error) { + if a.APITokenID == nil { + return nil, errors.New("api token id is required") + } + if a.APITokenName == "" { + return nil, errors.New("api token name is required") + } + + return json.Marshal(&a) +} + +type APITokenCreated struct { + *APITokenBase + APITokenDescription *string `json:"description,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func (a *APITokenCreated) ActionType() string { + return APITokenCreatedActionType +} + +func (a *APITokenCreated) ActionInfo() (json.RawMessage, error) { + _, err := a.APITokenBase.ActionInfo() + if err != nil { + return nil, fmt.Errorf("getting action info: %w", err) + } + + return json.Marshal(&a) +} + +func (a *APITokenCreated) Description() string { + if a.ExpiresAt != nil { + return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s expiring at %s", a.APITokenBase.APITokenName, a.ExpiresAt.Format(time.RFC3339)) + } + return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s", a.APITokenBase.APITokenName) +} + +type APITokenRevoked struct { + *APITokenBase +} + +func (a *APITokenRevoked) ActionType() string { + return APITokenRevokedActionType +} + +func (a *APITokenRevoked) ActionInfo() (json.RawMessage, error) { + _, err := a.APITokenBase.ActionInfo() + if err != nil { + return nil, fmt.Errorf("getting action info: %w", err) + } + + return json.Marshal(&a) +} + +func (a *APITokenRevoked) Description() string { + return fmt.Sprintf("{{ .ActorEmail }} has revoked the API token %s", a.APITokenBase.APITokenName) +} diff --git a/app/controlplane/pkg/auditor/events/apitoken_test.go b/app/controlplane/pkg/auditor/events/apitoken_test.go new file mode 100644 index 000000000..6b999689e --- /dev/null +++ b/app/controlplane/pkg/auditor/events/apitoken_test.go @@ -0,0 +1,139 @@ +// +// 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" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPITokenEvents(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) + apiTokenName := "test-token" + apiTokenDescription := "test description" + expirationDate, err := time.Parse(time.RFC3339, "2025-01-01T00:00:00Z") + require.NoError(t, err) + + tests := []struct { + name string + event auditor.LogEntry + expected string + actor auditor.ActorType + actorID uuid.UUID + }{ + { + name: "API Token created by user", + event: &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: uuidPtr(apiTokenUUID), + APITokenName: apiTokenName, + }, + }, + expected: "testdata/apitokens/api_token_created.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "API Token created with description by user", + event: &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: uuidPtr(apiTokenUUID), + APITokenName: apiTokenName, + }, + APITokenDescription: &apiTokenDescription, + }, + expected: "testdata/apitokens/api_token_created_with_description.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "API Token created with expires at by user", + event: &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: uuidPtr(apiTokenUUID), + APITokenName: apiTokenName, + }, + APITokenDescription: &apiTokenDescription, + ExpiresAt: &expirationDate, + }, + expected: "testdata/apitokens/api_token_created_with_expiration_date.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "API Token revoked by user", + event: &events.APITokenRevoked{ + APITokenBase: &events.APITokenBase{ + APITokenID: uuidPtr(apiTokenUUID), + APITokenName: apiTokenName, + }, + }, + expected: "testdata/apitokens/api_token_revoked.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)) + }) + } +} diff --git a/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created.json b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created.json new file mode 100644 index 000000000..c6d25f93e --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created.json @@ -0,0 +1,15 @@ +{ + "ActionType": "APITokenCreated", + "TargetType": "APIToken", + "TargetID": "2089bb36-e27b-428b-8009-d015c8737c55", + "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 API token test-token", + "Info": { + "api_token_id": "2089bb36-e27b-428b-8009-d015c8737c55", + "api_token_name": "test-token" + }, + "Digest": "sha256:99788c50f92df5a922f3c25b9cfbc676d70ac30180dbdf34e66efc0e0f2bd37f" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_description.json b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_description.json new file mode 100644 index 000000000..88f531ea1 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_description.json @@ -0,0 +1,16 @@ +{ + "ActionType": "APITokenCreated", + "TargetType": "APIToken", + "TargetID": "2089bb36-e27b-428b-8009-d015c8737c55", + "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 API token test-token", + "Info": { + "api_token_id": "2089bb36-e27b-428b-8009-d015c8737c55", + "api_token_name": "test-token", + "description": "test description" + }, + "Digest": "sha256:53df6d855b95c7d86e11b57af445306ee536abffa682fdbc0ecd1e58bd1e52ee" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_expiration_date.json b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_expiration_date.json new file mode 100644 index 000000000..91321975a --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_created_with_expiration_date.json @@ -0,0 +1,17 @@ +{ + "ActionType": "APITokenCreated", + "TargetType": "APIToken", + "TargetID": "2089bb36-e27b-428b-8009-d015c8737c55", + "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 API token test-token expiring at 2025-01-01T00:00:00Z", + "Info": { + "api_token_id": "2089bb36-e27b-428b-8009-d015c8737c55", + "api_token_name": "test-token", + "description": "test description", + "expires_at": "2025-01-01T00:00:00Z" + }, + "Digest": "sha256:69f991f315a482b12d037900fdf7de77c621e23ab26f2781663c99d64e297c0b" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_revoked.json b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_revoked.json new file mode 100644 index 000000000..d112f1a83 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/apitokens/api_token_revoked.json @@ -0,0 +1,15 @@ +{ + "ActionType": "APITokenRevoked", + "TargetType": "APIToken", + "TargetID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorType": "API_TOKEN", + "ActorID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorEmail": "", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": " has revoked the API token test-token", + "Info": { + "api_token_id": "2089bb36-e27b-428b-8009-d015c8737c55", + "api_token_name": "test-token" + }, + "Digest": "sha256:bc5eda0247c58cee660f7c83ce5378e3965db738c6c0b998138ee86313c802b8" +} \ No newline at end of file diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index d31a46c66..09909a904 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -21,10 +21,12 @@ import ( "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken" "github.com/chainloop-dev/chainloop/pkg/servicelogger" + "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" ) @@ -63,16 +65,18 @@ type APITokenUseCase struct { DefaultAuthzPolicies []*authz.Policy // Use Cases orgUseCase *OrganizationUseCase + auditorUC *AuditorUseCase } type APITokenSyncerUseCase struct { base *APITokenUseCase } -func NewAPITokenUseCase(apiTokenRepo APITokenRepo, conf *conf.Auth, authzE *authz.Enforcer, orgUseCase *OrganizationUseCase, logger log.Logger) (*APITokenUseCase, error) { +func NewAPITokenUseCase(apiTokenRepo APITokenRepo, conf *conf.Auth, authzE *authz.Enforcer, orgUseCase *OrganizationUseCase, auditorUC *AuditorUseCase, logger log.Logger) (*APITokenUseCase, error) { uc := &APITokenUseCase{ apiTokenRepo: apiTokenRepo, orgUseCase: orgUseCase, + auditorUC: auditorUC, logger: servicelogger.ScopedHelper(logger, "biz/APITokenUseCase"), enforcer: authzE, DefaultAuthzPolicies: []*authz.Policy{ @@ -164,6 +168,16 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description return nil, fmt.Errorf("adding default policies: %w", err) } + // Dispatch the event to the auditor to notify the creation of the token + uc.auditorUC.Dispatch(ctx, &events.APITokenCreated{ + APITokenBase: &events.APITokenBase{ + APITokenID: &token.ID, + APITokenName: name, + }, + APITokenDescription: description, + ExpiresAt: expiresAt, + }, &orgUUID) + return token, nil } @@ -182,7 +196,7 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { return NewErrInvalidUUID(err) } - uuid, err := uuid.Parse(id) + tokenUUID, err := uuid.Parse(id) if err != nil { return NewErrInvalidUUID(err) } @@ -192,7 +206,24 @@ func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error { return fmt.Errorf("removing policies: %w", err) } - return uc.apiTokenRepo.Revoke(ctx, orgUUID, uuid) + token, err := uc.apiTokenRepo.FindByID(ctx, tokenUUID) + if err != nil { + return fmt.Errorf("finding token: %w", err) + } + + if rvErr := uc.apiTokenRepo.Revoke(ctx, orgUUID, tokenUUID); rvErr != nil { + return fmt.Errorf("revoking token: %w", rvErr) + } + + // Dispatch the event to the auditor to notify the revocation of the token + uc.auditorUC.Dispatch(ctx, &events.APITokenRevoked{ + APITokenBase: &events.APITokenBase{ + APITokenID: &tokenUUID, + APITokenName: token.Name, + }, + }, &orgUUID) + + return nil } func (uc *APITokenUseCase) FindByNameInOrg(ctx context.Context, orgID, name string) (*APIToken, error) { diff --git a/app/controlplane/pkg/biz/auditor.go b/app/controlplane/pkg/biz/auditor.go index bbcee9181..215bdfd72 100644 --- a/app/controlplane/pkg/biz/auditor.go +++ b/app/controlplane/pkg/biz/auditor.go @@ -41,7 +41,7 @@ func NewAuditorUseCase(p *auditor.AuditLogPublisher, logger log.Logger) *Auditor // Dispatch logs an entry to the audit log asynchronously. func (uc *AuditorUseCase) Dispatch(ctx context.Context, entry auditor.LogEntry, orgID *uuid.UUID) { // dynamically load user information from the context - opts := []auditor.GeneratorOption{} + var opts []auditor.GeneratorOption var gotActor bool if user := entities.CurrentUser(ctx); user != nil { parsedUUID, _ := uuid.Parse(user.ID) diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 8a351c0cb..9b1599936 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -134,7 +134,7 @@ func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, r cleanup() return nil, nil, err } - apiTokenUseCase, err := biz.NewAPITokenUseCase(apiTokenRepo, auth, enforcer, organizationUseCase, logger) + apiTokenUseCase, err := biz.NewAPITokenUseCase(apiTokenRepo, auth, enforcer, organizationUseCase, auditorUseCase, logger) if err != nil { cleanup() return nil, nil, err From d9627c2ab419b14e1560fc381dd745717855c05e Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 12:49:23 +0200 Subject: [PATCH 2/3] fix linter Signed-off-by: Javier Rodriguez --- .../internal/usercontext/apitoken_middleware_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/internal/usercontext/apitoken_middleware_test.go b/app/controlplane/internal/usercontext/apitoken_middleware_test.go index 6ed2e683c..1f88a000b 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware_test.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware_test.go @@ -100,7 +100,7 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { t.Run(tc.name, func(t *testing.T) { apiTokenRepo := bizMocks.NewAPITokenRepo(t) orgRepo := bizMocks.NewOrganizationRepo(t) - apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &conf.Auth{GeneratedJwsHmacSecret: "test"}, nil, nil, nil) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &conf.Auth{GeneratedJwsHmacSecret: "test"}, nil, nil, nil, nil) require.NoError(t, err) orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) require.NoError(t, err) From 8eaea10fe8c46fb75f1881f967d7fe241bde9be5 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 1 May 2025 12:57:21 +0200 Subject: [PATCH 3/3] fix linter Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/auditor/events/apitoken.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controlplane/pkg/auditor/events/apitoken.go b/app/controlplane/pkg/auditor/events/apitoken.go index ce766a079..48df4d29d 100644 --- a/app/controlplane/pkg/auditor/events/apitoken.go +++ b/app/controlplane/pkg/auditor/events/apitoken.go @@ -86,9 +86,9 @@ func (a *APITokenCreated) ActionInfo() (json.RawMessage, error) { func (a *APITokenCreated) Description() string { if a.ExpiresAt != nil { - return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s expiring at %s", a.APITokenBase.APITokenName, a.ExpiresAt.Format(time.RFC3339)) + return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s expiring at %s", a.APITokenName, a.ExpiresAt.Format(time.RFC3339)) } - return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s", a.APITokenBase.APITokenName) + return fmt.Sprintf("{{ .ActorEmail }} has created the API token %s", a.APITokenName) } type APITokenRevoked struct { @@ -109,5 +109,5 @@ func (a *APITokenRevoked) ActionInfo() (json.RawMessage, error) { } func (a *APITokenRevoked) Description() string { - return fmt.Sprintf("{{ .ActorEmail }} has revoked the API token %s", a.APITokenBase.APITokenName) + return fmt.Sprintf("{{ .ActorEmail }} has revoked the API token %s", a.APITokenName) }