Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions pkg/account/api/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,6 @@ import (
eventproto "github.com/bucketeer-io/bucketeer/v2/proto/event/domain"
)

const (
// apiKeyVisibleChars is the number of leading and trailing characters shown when returning API keys (get/list).
// The middle is replaced with dots.
apiKeyVisibleChars = 4
)

// obfuscateAPIKey obfuscates the API key by showing the first and last apiKeyVisibleChars characters,
// with dots in the middle (same pattern as pkg/api/api/api_grpc.go obfuscateString).
func obfuscateAPIKey(input string) string {
if len(input) > apiKeyVisibleChars*2 {
return input[:apiKeyVisibleChars] + "...." + input[len(input)-apiKeyVisibleChars:]
}
return input
}

func (s *AccountService) CreateAPIKey(
ctx context.Context,
req *proto.CreateAPIKeyRequest,
Expand Down Expand Up @@ -116,6 +101,8 @@ func (s *AccountService) CreateAPIKey(
ApiKey: key.ApiKey,
},
req.EnvironmentId,
// The domain event keeps the raw key: it is where the api key cache pipeline resolves the
// cache key from. It is obfuscated when the audit log is created.
key.APIKey,
nil,
)
Expand Down Expand Up @@ -178,7 +165,7 @@ func (s *AccountService) GetAPIKey(ctx context.Context, req *proto.GetAPIKeyRequ
}

// for security, obfuscate the returned key: show first and last N characters
apiKey.ApiKey = obfuscateAPIKey(apiKey.ApiKey)
apiKey.ApiKey = domain.ObfuscateAPIKey(apiKey.ApiKey)

return &proto.GetAPIKeyResponse{ApiKey: apiKey.APIKey}, nil
}
Expand Down Expand Up @@ -238,7 +225,7 @@ func (s *AccountService) ListAPIKeys(

// for security, obfuscate the returned key: show first and last N characters
for i := 0; i < len(apiKeys); i++ {
apiKeys[i].ApiKey = obfuscateAPIKey(apiKeys[i].ApiKey)
apiKeys[i].ApiKey = domain.ObfuscateAPIKey(apiKeys[i].ApiKey)
}

return &proto.ListAPIKeysResponse{
Expand Down Expand Up @@ -296,13 +283,13 @@ func (s *AccountService) GetEnvironmentAPIKey(
"Failed to get environment api key",
log.FieldsFromIncomingContext(ctx).AddFields(
zap.Error(err),
zap.String("apiKey", obfuscateAPIKey(req.ApiKey)),
zap.String("apiKey", domain.ObfuscateAPIKey(req.ApiKey)),
)...,
)
return nil, api.NewGRPCStatus(err).Err()
}
// for security, obfuscate the returned key: show first and last N characters
envAPIKey.ApiKey.ApiKey = obfuscateAPIKey(envAPIKey.ApiKey.ApiKey)
envAPIKey.ApiKey.ApiKey = domain.ObfuscateAPIKey(envAPIKey.ApiKey.ApiKey)

return &proto.GetEnvironmentAPIKeyResponse{
EnvironmentApiKey: envAPIKey.EnvironmentAPIKey,
Expand Down
112 changes: 112 additions & 0 deletions pkg/account/api/api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/wrapperspb"

"github.com/bucketeer-io/bucketeer/v2/pkg/account/domain"
v2as "github.com/bucketeer-io/bucketeer/v2/pkg/account/storage/v2"
accstoragemock "github.com/bucketeer-io/bucketeer/v2/pkg/account/storage/v2/mock"
"github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
domaineventdomain "github.com/bucketeer-io/bucketeer/v2/pkg/domainevent/domain"
pkgErr "github.com/bucketeer-io/bucketeer/v2/pkg/error"
"github.com/bucketeer-io/bucketeer/v2/pkg/pubsub/publisher"
publishermock "github.com/bucketeer-io/bucketeer/v2/pkg/pubsub/publisher/mock"
dbmock "github.com/bucketeer-io/bucketeer/v2/pkg/storage/v2/database/mock"
accountproto "github.com/bucketeer-io/bucketeer/v2/proto/account"
eventproto "github.com/bucketeer-io/bucketeer/v2/proto/event/domain"
)

func TestCreateAPIKeyMySQL(t *testing.T) {
Expand Down Expand Up @@ -575,3 +579,111 @@ func TestListAPIKeysMySQL(t *testing.T) {
})
}
}

func TestCreateAPIKeyDomainEvent(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

ctx := setToken(context.Background(), true)
service := createAccountService(t, mockController, nil)
service.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2ByEnvironmentID(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(&domain.AccountV2{
AccountV2: &accountproto.AccountV2{
Email: "bucketeer@bucketeer.io",
OrganizationRole: accountproto.AccountV2_Role_Organization_ADMIN,
},
}, nil)
service.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().CreateAPIKey(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(nil)
service.dbClient.(*dbmock.MockClient).EXPECT().RunInTransactionV2(
gomock.Any(), gomock.Any(),
).Do(func(ctx context.Context, fn func(ctx context.Context) error) {
require.NoError(t, fn(ctx))
}).Return(nil)
var published *eventproto.Event
service.publisher.(*publishermock.MockPublisher).EXPECT().Publish(
gomock.Any(), gomock.Any(),
).Do(func(ctx context.Context, msg publisher.Message) {
published = msg.(*eventproto.Event)
}).Return(nil)

res, err := service.CreateAPIKey(ctx, &accountproto.CreateAPIKeyRequest{
Name: "name",
Maintainer: "bucketeer@bucketeer.io",
Role: accountproto.APIKey_SDK_CLIENT,
Description: "test key",
})
require.NoError(t, err)
rawAPIKey := res.ApiKey.ApiKey
require.NotEmpty(t, rawAPIKey)

// The domain event keeps the raw key, which the api key cache pipeline resolves the cache key
// from. It is obfuscated when the audit log is created.
require.NotNil(t, published)
created := &eventproto.APIKeyCreatedEvent{}
require.NoError(t, published.Data.UnmarshalTo(created))
assert.Equal(t, rawAPIKey, created.ApiKey)
secrets, err := domaineventdomain.ExtractAPIKeySecrets(published)
require.NoError(t, err)
assert.Equal(t, []string{rawAPIKey}, secrets)
}

func TestUpdateAPIKeyDomainEvent(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

rawAPIKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

ctx := setToken(context.Background(), true)
service := createAccountService(t, mockController, nil)
service.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAccountV2ByEnvironmentID(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(&domain.AccountV2{
AccountV2: &accountproto.AccountV2{
Email: "bucketeer@bucketeer.io",
OrganizationRole: accountproto.AccountV2_Role_Organization_ADMIN,
},
}, nil)
service.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().GetAPIKey(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(&domain.APIKey{
APIKey: &accountproto.APIKey{
Id: "id-1",
Name: "name",
ApiKey: rawAPIKey,
Role: accountproto.APIKey_SDK_CLIENT,
},
}, nil)
service.accountStorage.(*accstoragemock.MockAccountStorage).EXPECT().UpdateAPIKey(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(nil)
service.dbClient.(*dbmock.MockClient).EXPECT().RunInTransactionV2(
gomock.Any(), gomock.Any(),
).Do(func(ctx context.Context, fn func(ctx context.Context) error) {
require.NoError(t, fn(ctx))
}).Return(nil)
var published *eventproto.Event
service.publisher.(*publishermock.MockPublisher).EXPECT().Publish(
gomock.Any(), gomock.Any(),
).Do(func(ctx context.Context, msg publisher.Message) {
published = msg.(*eventproto.Event)
}).Return(nil)

_, err := service.UpdateAPIKey(ctx, &accountproto.UpdateAPIKeyRequest{
Id: "id-1",
EnvironmentId: "env-1",
Name: wrapperspb.String("new name"),
})
require.NoError(t, err)

require.NotNil(t, published)
// The domain event keeps the raw key, which the api key cache pipeline resolves the cache key
// from. It is obfuscated when the audit log is created.
secrets, err := domaineventdomain.ExtractAPIKeySecrets(published)
require.NoError(t, err)
assert.Equal(t, []string{rawAPIKey}, secrets)
}
14 changes: 13 additions & 1 deletion pkg/account/domain/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,19 @@ import (
proto "github.com/bucketeer-io/bucketeer/v2/proto/account"
)

const keyBytes = 32
const (
keyBytes = 32
apiKeyVisibleChars = 4
)

// ObfuscateAPIKey shows the first and last apiKeyVisibleChars characters, with dots in the middle.
// It must be used everywhere an API key is returned, stored or logged, except when it is created.
Comment thread
hvn2k1 marked this conversation as resolved.
func ObfuscateAPIKey(input string) string {
if len(input) > apiKeyVisibleChars*2 {
return input[:apiKeyVisibleChars] + "...." + input[len(input)-apiKeyVisibleChars:]
}
return input
}

var (
ErrLastUsedAtNotUpdated = pkgErr.NewErrorFailedPrecondition(
Expand Down
39 changes: 39 additions & 0 deletions pkg/account/domain/api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,42 @@ func TestAPIKeyDisable(t *testing.T) {
a.Disable()
assert.Equal(t, true, a.Disabled)
}

func TestObfuscateAPIKey(t *testing.T) {
patterns := []struct {
desc string
input string
expected string
}{
{
desc: "empty",
input: "",
expected: "",
},
{
desc: "shorter than the visible characters: not obfuscated",
input: "abc",
expected: "abc",
},
{
desc: "same length as the visible characters: not obfuscated",
input: "abcdefgh",
expected: "abcdefgh",
},
{
desc: "longer than the visible characters: obfuscated",
input: "abcdefghi",
expected: "abcd....fghi",
},
{
desc: "generated key length: obfuscated",
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
expected: "0123....cdef",
},
}
for _, p := range patterns {
t.Run(p.desc, func(t *testing.T) {
assert.Equal(t, p.expected, ObfuscateAPIKey(p.input))
})
}
}
3 changes: 3 additions & 0 deletions pkg/auditlog/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func (s *auditlogService) GetAuditLog(
return nil, api.NewGRPCStatus(err).Err()
}
auditlog.LocalizedMessage = domainevent.LocalizedMessage(auditlog.Type, localizer)
s.obfuscateAPIKey(auditlog)

accounts, err := s.getAccountMapByEmails(ctx, []string{auditlog.Editor.Email}, req.EnvironmentId)
if err != nil {
Expand Down Expand Up @@ -243,6 +244,7 @@ func (s *auditlogService) ListAuditLogs(
}
auditlogs[i].LocalizedMessage = domainevent.LocalizedMessage(auditlogs[i].Type, localizer)
}
s.obfuscateAPIKeys(auditlogs)

return &proto.ListAuditLogsResponse{
AuditLogs: auditlogs,
Expand Down Expand Up @@ -306,6 +308,7 @@ func (s *auditlogService) ListAdminAuditLogs(
for _, auditlog := range auditlogs {
auditlog.LocalizedMessage = domainevent.LocalizedMessage(auditlog.Type, localizer)
}
s.obfuscateAPIKeys(auditlogs)
return &proto.ListAdminAuditLogsResponse{
AuditLogs: auditlogs,
Cursor: strconv.Itoa(nextCursor),
Expand Down
39 changes: 39 additions & 0 deletions pkg/auditlog/api/obfuscate_api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2026 The Bucketeer 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 api

import (
"go.uber.org/zap"

"github.com/bucketeer-io/bucketeer/v2/pkg/auditlog/domain"
proto "github.com/bucketeer-io/bucketeer/v2/proto/auditlog"
)

// obfuscateAPIKeys obfuscates the API keys of the audit logs saved before they were obfuscated
// at creation. The rows saved since then are already obfuscated.
func (s *auditlogService) obfuscateAPIKeys(auditlogs []*proto.AuditLog) {
for i := range auditlogs {
s.obfuscateAPIKey(auditlogs[i])
}
}

func (s *auditlogService) obfuscateAPIKey(auditlog *proto.AuditLog) {
if err := domain.ObfuscateAPIKey(auditlog); err != nil {
s.logger.Error("Failed to obfuscate the api key of the audit log",
zap.Error(err),
zap.String("id", auditlog.Id),
)
}
}
5 changes: 4 additions & 1 deletion pkg/auditlog/domain/auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type AuditLog struct {
}

func NewAuditLog(event *domainevent.Event, environmentId string) *AuditLog {
return &AuditLog{
auditlog := &AuditLog{
AuditLog: &proto.AuditLog{
Id: event.Id,
Timestamp: event.Timestamp,
Expand All @@ -40,4 +40,7 @@ func NewAuditLog(event *domainevent.Event, environmentId string) *AuditLog {
},
EnvironmentId: environmentId,
}
// On failure the affected field is left empty rather than persisting a raw key.
_ = ObfuscateAPIKey(auditlog.AuditLog)
return auditlog
}
Loading
Loading