From 498f25c26aed4e763d7c24b1eeed4ae784351983 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Tue, 26 May 2026 08:06:21 -0300 Subject: [PATCH] =?UTF-8?q?refactor:=20deepen=20module=20seams=20=E2=80=94?= =?UTF-8?q?=20inline=20SHA256,=20add=20NullSigner,=20move=20repository=20i?= =?UTF-8?q?nterfaces=20to=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent architectural improvements: 1. Delete SHA256HashService shallow interface and inline the hash function directly into tokenizationUseCase as a private tokenValueHash helper. The interface was a pass-through with no leverage; inlining gives callers real hash values in tests instead of mocked stubs. 2. Add keyring.NullSigner{} as an explicit no-op KeySigner for tests that do not exercise signing behaviour, and remove the silent nil-guard code path in AuditLogUseCase. Legacy unsigned audit logs in integration tests are now written directly via the repository, correctly modelling data that predates the signing feature. 3. Move repository interfaces (SecretRepository, TokenizationKeyRepository, TokenRepository, ClientRepository, AuditLogRepository) from usecase packages into their corresponding domain packages, following the pattern already established by transit/domain. Remove the TransitKeyRepository type alias from transit/usecase. All usecase implementations and DI wiring updated to use the domain-qualified names. Co-Authored-By: Claude Sonnet 4.6 --- internal/app/di.go | 18 ++- internal/app/di_auth.go | 19 +-- internal/app/di_secrets.go | 7 +- internal/app/di_tokenization.go | 16 +- internal/app/di_transit.go | 7 +- internal/auth/domain/repository.go | 88 +++++++++++ internal/auth/usecase/audit_log_usecase.go | 44 +++--- .../auth/usecase/audit_log_usecase_test.go | 29 ++-- internal/auth/usecase/client_usecase.go | 8 +- internal/auth/usecase/interface.go | 80 ---------- internal/auth/usecase/token_usecase.go | 8 +- internal/keyring/null_signer.go | 13 ++ internal/secrets/domain/repository.go | 33 ++++ internal/secrets/usecase/interface.go | 28 ---- internal/secrets/usecase/secret_usecase.go | 4 +- internal/tokenization/domain/repository.go | 58 +++++++ internal/tokenization/usecase/hash_service.go | 33 ---- .../tokenization/usecase/hash_service_test.go | 143 ------------------ internal/tokenization/usecase/interface.go | 52 ------- internal/tokenization/usecase/mocks/mocks.go | 83 ---------- .../usecase/tokenization_key_usecase.go | 4 +- .../usecase/tokenization_usecase.go | 32 ++-- .../usecase/tokenization_usecase_test.go | 48 +++--- internal/transit/usecase/interface.go | 4 - .../transit/usecase/transit_key_usecase.go | 4 +- test/integration/audit_log_signature_test.go | 62 ++++---- 26 files changed, 349 insertions(+), 576 deletions(-) create mode 100644 internal/auth/domain/repository.go create mode 100644 internal/keyring/null_signer.go create mode 100644 internal/secrets/domain/repository.go create mode 100644 internal/tokenization/domain/repository.go delete mode 100644 internal/tokenization/usecase/hash_service.go delete mode 100644 internal/tokenization/usecase/hash_service_test.go diff --git a/internal/app/di.go b/internal/app/di.go index 84a33b0..2dba698 100644 --- a/internal/app/di.go +++ b/internal/app/di.go @@ -9,6 +9,7 @@ import ( "os" "sync" + authDomain "github.com/allisson/secrets/internal/auth/domain" authHTTP "github.com/allisson/secrets/internal/auth/http" authService "github.com/allisson/secrets/internal/auth/service" authUseCase "github.com/allisson/secrets/internal/auth/usecase" @@ -17,10 +18,13 @@ import ( "github.com/allisson/secrets/internal/http" "github.com/allisson/secrets/internal/keyring" "github.com/allisson/secrets/internal/metrics" + secretsDomain "github.com/allisson/secrets/internal/secrets/domain" secretsHTTP "github.com/allisson/secrets/internal/secrets/http" secretsUseCase "github.com/allisson/secrets/internal/secrets/usecase" + tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" tokenizationHTTP "github.com/allisson/secrets/internal/tokenization/http" tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" + transitDomain "github.com/allisson/secrets/internal/transit/domain" transitHTTP "github.com/allisson/secrets/internal/transit/http" transitUseCase "github.com/allisson/secrets/internal/transit/usecase" ) @@ -52,13 +56,13 @@ type Container struct { keyring once[keyring.Keyring] // Repositories - secretRepository once[secretsUseCase.SecretRepository] - clientRepository once[authUseCase.ClientRepository] - tokenRepository once[authUseCase.TokenRepository] - auditLogRepository once[authUseCase.AuditLogRepository] - transitKeyRepository once[transitUseCase.TransitKeyRepository] - tokenizationKeyRepository once[tokenizationUseCase.TokenizationKeyRepository] - tokenizationTokenRepository once[tokenizationUseCase.TokenRepository] + secretRepository once[secretsDomain.SecretRepository] + clientRepository once[authDomain.ClientRepository] + tokenRepository once[authDomain.TokenRepository] + auditLogRepository once[authDomain.AuditLogRepository] + transitKeyRepository once[transitDomain.TransitKeyRepository] + tokenizationKeyRepository once[tokenizationDomain.TokenizationKeyRepository] + tokenizationTokenRepository once[tokenizationDomain.TokenRepository] // Use Cases kekUseCase once[keyring.KekUseCase] diff --git a/internal/app/di_auth.go b/internal/app/di_auth.go index f3a91bb..a90257a 100644 --- a/internal/app/di_auth.go +++ b/internal/app/di_auth.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + authDomain "github.com/allisson/secrets/internal/auth/domain" authHTTP "github.com/allisson/secrets/internal/auth/http" authRepository "github.com/allisson/secrets/internal/auth/repository" authService "github.com/allisson/secrets/internal/auth/service" @@ -19,8 +20,8 @@ func (c *Container) SecretService() authService.SecretService { } // ClientRepository returns the client repository. -func (c *Container) ClientRepository(ctx context.Context) (authUseCase.ClientRepository, error) { - return c.clientRepository.get(func() (authUseCase.ClientRepository, error) { +func (c *Container) ClientRepository(ctx context.Context) (authDomain.ClientRepository, error) { + return c.clientRepository.get(func() (authDomain.ClientRepository, error) { return c.initClientRepository(ctx) }) } @@ -41,15 +42,15 @@ func (c *Container) TokenService() authService.TokenService { } // TokenRepository returns the token repository. -func (c *Container) TokenRepository(ctx context.Context) (authUseCase.TokenRepository, error) { - return c.tokenRepository.get(func() (authUseCase.TokenRepository, error) { +func (c *Container) TokenRepository(ctx context.Context) (authDomain.TokenRepository, error) { + return c.tokenRepository.get(func() (authDomain.TokenRepository, error) { return c.initTokenRepository(ctx) }) } // AuditLogRepository returns the audit log repository. -func (c *Container) AuditLogRepository(ctx context.Context) (authUseCase.AuditLogRepository, error) { - return c.auditLogRepository.get(func() (authUseCase.AuditLogRepository, error) { +func (c *Container) AuditLogRepository(ctx context.Context) (authDomain.AuditLogRepository, error) { + return c.auditLogRepository.get(func() (authDomain.AuditLogRepository, error) { return c.initAuditLogRepository(ctx) }) } @@ -95,7 +96,7 @@ func (c *Container) initSecretService() authService.SecretService { } // initClientRepository creates the client repository. -func (c *Container) initClientRepository(ctx context.Context) (authUseCase.ClientRepository, error) { +func (c *Container) initClientRepository(ctx context.Context) (authDomain.ClientRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for client repository: %w", err) @@ -143,7 +144,7 @@ func (c *Container) initTokenService() authService.TokenService { } // initTokenRepository creates the token repository. -func (c *Container) initTokenRepository(ctx context.Context) (authUseCase.TokenRepository, error) { +func (c *Container) initTokenRepository(ctx context.Context) (authDomain.TokenRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for token repository: %w", err) @@ -153,7 +154,7 @@ func (c *Container) initTokenRepository(ctx context.Context) (authUseCase.TokenR } // initAuditLogRepository creates the audit log repository. -func (c *Container) initAuditLogRepository(ctx context.Context) (authUseCase.AuditLogRepository, error) { +func (c *Container) initAuditLogRepository(ctx context.Context) (authDomain.AuditLogRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for audit log repository: %w", err) diff --git a/internal/app/di_secrets.go b/internal/app/di_secrets.go index 1418299..2de7231 100644 --- a/internal/app/di_secrets.go +++ b/internal/app/di_secrets.go @@ -4,14 +4,15 @@ import ( "context" "fmt" + secretsDomain "github.com/allisson/secrets/internal/secrets/domain" secretsHTTP "github.com/allisson/secrets/internal/secrets/http" secretsRepository "github.com/allisson/secrets/internal/secrets/repository" secretsUseCase "github.com/allisson/secrets/internal/secrets/usecase" ) // SecretRepository returns the secret repository. -func (c *Container) SecretRepository(ctx context.Context) (secretsUseCase.SecretRepository, error) { - return c.secretRepository.get(func() (secretsUseCase.SecretRepository, error) { +func (c *Container) SecretRepository(ctx context.Context) (secretsDomain.SecretRepository, error) { + return c.secretRepository.get(func() (secretsDomain.SecretRepository, error) { return c.initSecretRepository(ctx) }) } @@ -30,7 +31,7 @@ func (c *Container) SecretHandler(ctx context.Context) (*secretsHTTP.SecretHandl }) } -func (c *Container) initSecretRepository(ctx context.Context) (secretsUseCase.SecretRepository, error) { +func (c *Container) initSecretRepository(ctx context.Context) (secretsDomain.SecretRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for secret repository: %w", err) diff --git a/internal/app/di_tokenization.go b/internal/app/di_tokenization.go index e1a7ee7..91fd836 100644 --- a/internal/app/di_tokenization.go +++ b/internal/app/di_tokenization.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" tokenizationHTTP "github.com/allisson/secrets/internal/tokenization/http" tokenizationRepository "github.com/allisson/secrets/internal/tokenization/repository" tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" @@ -11,16 +12,16 @@ import ( func (c *Container) TokenizationKeyRepository( ctx context.Context, -) (tokenizationUseCase.TokenizationKeyRepository, error) { - return c.tokenizationKeyRepository.get(func() (tokenizationUseCase.TokenizationKeyRepository, error) { +) (tokenizationDomain.TokenizationKeyRepository, error) { + return c.tokenizationKeyRepository.get(func() (tokenizationDomain.TokenizationKeyRepository, error) { return c.initTokenizationKeyRepository(ctx) }) } func (c *Container) TokenizationTokenRepository( ctx context.Context, -) (tokenizationUseCase.TokenRepository, error) { - return c.tokenizationTokenRepository.get(func() (tokenizationUseCase.TokenRepository, error) { +) (tokenizationDomain.TokenRepository, error) { + return c.tokenizationTokenRepository.get(func() (tokenizationDomain.TokenRepository, error) { return c.initTokenizationTokenRepository(ctx) }) } @@ -57,7 +58,7 @@ func (c *Container) TokenizationHandler(ctx context.Context) (*tokenizationHTTP. func (c *Container) initTokenizationKeyRepository( ctx context.Context, -) (tokenizationUseCase.TokenizationKeyRepository, error) { +) (tokenizationDomain.TokenizationKeyRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for tokenization key repository: %w", err) @@ -68,7 +69,7 @@ func (c *Container) initTokenizationKeyRepository( func (c *Container) initTokenizationTokenRepository( ctx context.Context, -) (tokenizationUseCase.TokenRepository, error) { +) (tokenizationDomain.TokenRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for tokenization token repository: %w", err) @@ -127,13 +128,10 @@ func (c *Container) initTokenizationUseCase( return nil, fmt.Errorf("failed to get keyring for tokenization use case: %w", err) } - hashService := tokenizationUseCase.NewSHA256HashService() - return tokenizationUseCase.NewTokenizationUseCase( txManager, tokenizationKeyRepository, tokenRepository, - hashService, kr, ), nil } diff --git a/internal/app/di_transit.go b/internal/app/di_transit.go index 080b2ef..01b8399 100644 --- a/internal/app/di_transit.go +++ b/internal/app/di_transit.go @@ -4,13 +4,14 @@ import ( "context" "fmt" + transitDomain "github.com/allisson/secrets/internal/transit/domain" transitHTTP "github.com/allisson/secrets/internal/transit/http" transitRepository "github.com/allisson/secrets/internal/transit/repository" transitUseCase "github.com/allisson/secrets/internal/transit/usecase" ) -func (c *Container) TransitKeyRepository(ctx context.Context) (transitUseCase.TransitKeyRepository, error) { - return c.transitKeyRepository.get(func() (transitUseCase.TransitKeyRepository, error) { +func (c *Container) TransitKeyRepository(ctx context.Context) (transitDomain.TransitKeyRepository, error) { + return c.transitKeyRepository.get(func() (transitDomain.TransitKeyRepository, error) { return c.initTransitKeyRepository(ctx) }) } @@ -35,7 +36,7 @@ func (c *Container) CryptoHandler(ctx context.Context) (*transitHTTP.CryptoHandl func (c *Container) initTransitKeyRepository( ctx context.Context, -) (transitUseCase.TransitKeyRepository, error) { +) (transitDomain.TransitKeyRepository, error) { db, err := c.DB(ctx) if err != nil { return nil, fmt.Errorf("failed to get database for transit key repository: %w", err) diff --git a/internal/auth/domain/repository.go b/internal/auth/domain/repository.go new file mode 100644 index 0000000..9954ade --- /dev/null +++ b/internal/auth/domain/repository.go @@ -0,0 +1,88 @@ +package domain + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// ClientRepository defines persistence operations for authentication clients. +// Implementations must support transaction-aware operations via context propagation. +type ClientRepository interface { + // Create stores a new client in the repository. + Create(ctx context.Context, client *Client) error + + // Update modifies an existing client in the repository. + Update(ctx context.Context, client *Client) error + + // Get retrieves a client by ID. Returns ErrClientNotFound if not found. + Get(ctx context.Context, clientID uuid.UUID) (*Client, error) + + // ListCursor retrieves clients ordered by ID descending (newest first) with cursor-based pagination. + // If afterID is provided, returns clients with ID less than afterID (DESC order). + // Returns empty slice if no clients found. Limit is pre-validated (1-1000). + ListCursor(ctx context.Context, afterID *uuid.UUID, limit int) ([]*Client, error) + + // UpdateLockState atomically updates the failed attempt counter and lock expiry. + // Pass failedAttempts=0 and lockedUntil=nil to reset the lock on successful auth. + UpdateLockState(ctx context.Context, clientID uuid.UUID, failedAttempts int, lockedUntil *time.Time) error +} + +// TokenRepository defines persistence operations for authentication tokens. +// Implementations must support transaction-aware operations via context propagation. +type TokenRepository interface { + // Create stores a new token in the repository. + Create(ctx context.Context, token *Token) error + + // Update modifies an existing token in the repository. + Update(ctx context.Context, token *Token) error + + // Get retrieves a token by ID. Returns ErrTokenNotFound if not found. + Get(ctx context.Context, tokenID uuid.UUID) (*Token, error) + + // GetByTokenHash retrieves a token by its SHA-256 hash value. + // Returns ErrTokenNotFound if no token matches the hash. + GetByTokenHash(ctx context.Context, tokenHash string) (*Token, error) + + // RevokeByTokenID marks a specific token as revoked by setting its revoked_at timestamp. + RevokeByTokenID(ctx context.Context, tokenID uuid.UUID) error + + // RevokeByClientID marks all active tokens for a specific client as revoked. + RevokeByClientID(ctx context.Context, clientID uuid.UUID) error + + // PurgeExpiredAndRevoked permanently deletes tokens that are either expired or revoked + // and were created before the specified timestamp. Returns the number of deleted tokens. + PurgeExpiredAndRevoked(ctx context.Context, olderThan time.Time) (int64, error) +} + +// AuditLogRepository defines persistence operations for audit logs. +// Implementations must support transaction-aware operations via context propagation. +type AuditLogRepository interface { + // Create stores a new audit log entry recording an authorization decision. + // Returns error if the audit log ID already exists or database operation fails. + Create(ctx context.Context, auditLog *AuditLog) error + + // Get retrieves a single audit log by ID. Returns error if not found. + // Used for signature verification of specific audit logs. + Get(ctx context.Context, id uuid.UUID) (*AuditLog, error) + + // ListCursor retrieves audit logs ordered by created_at descending (newest first) with cursor-based pagination + // and optional time-based filtering. If afterID is provided, returns logs with ID greater than afterID (UUIDv7 ordering). + // Accepts createdAtFrom and createdAtTo as optional filters (nil means no filter). Both boundaries are inclusive (>= and <=). + // Accepts clientID as an optional filter (nil means no filter). + // All timestamps are expected in UTC. Returns empty slice if no audit logs found. Limit is pre-validated (1-1000). + ListCursor( + ctx context.Context, + afterID *uuid.UUID, + limit int, + createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, + ) ([]*AuditLog, error) + + // DeleteOlderThan removes audit logs with created_at before the specified timestamp. + // When dryRun is true, returns count via SELECT COUNT(*) without deletion. When false, + // executes DELETE and returns affected rows. Supports transaction-aware operations via + // context propagation. All timestamps are expected in UTC. + DeleteOlderThan(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) +} diff --git a/internal/auth/usecase/audit_log_usecase.go b/internal/auth/usecase/audit_log_usecase.go index d0d7aab..de7082a 100644 --- a/internal/auth/usecase/audit_log_usecase.go +++ b/internal/auth/usecase/audit_log_usecase.go @@ -16,14 +16,13 @@ import ( // auditLogUseCase implements AuditLogUseCase interface for recording and verifying audit logs. // Provides cryptographic signing with HMAC-SHA256 for tamper detection. type auditLogUseCase struct { - auditLogRepo AuditLogRepository + auditLogRepo authDomain.AuditLogRepository keySigner keyring.KeySigner } // Create records an audit log entry for an authenticated operation. Generates a unique -// UUIDv7 identifier and timestamp, then signs the log with HMAC-SHA256 using the active KEK -// if a keyring.KeySigner is provided. For legacy/testing scenarios without signing, -// creates unsigned audit logs. The metadata parameter is optional and can be nil. +// UUIDv7 identifier and timestamp, signs the log with HMAC-SHA256 via keySigner, and +// persists it. The metadata parameter is optional and can be nil. func (a *auditLogUseCase) Create( ctx context.Context, requestID uuid.UUID, @@ -32,10 +31,8 @@ func (a *auditLogUseCase) Create( path string, metadata map[string]any, ) (err error) { - // Create the audit log entity - // Truncate timestamp to microsecond precision to match database storage (PostgreSQL TIMESTAMPTZ - // ). This ensures the signature matches the value - // retrieved from the database during verification. + // Truncate timestamp to microsecond precision to match database storage (PostgreSQL TIMESTAMPTZ). + // This ensures the signature matches the value retrieved from the database during verification. auditLog := &authDomain.AuditLog{ ID: uuid.Must(uuid.NewV7()), RequestID: requestID, @@ -44,27 +41,22 @@ func (a *auditLogUseCase) Create( Path: path, Metadata: metadata, CreatedAt: time.Now().UTC().Truncate(time.Microsecond), - IsSigned: false, // Default to unsigned } - // Sign the audit log if a KeySigner is available - if a.keySigner != nil { - canonical, err := auditLog.Canonical() - if err != nil { - return apperrors.Wrap(err, "failed to canonicalize audit log for signing") - } - - signature, kekID, err := a.keySigner.SignWithKey(canonical) - if err != nil { - return apperrors.Wrap(err, "failed to sign audit log") - } + canonical, err := auditLog.Canonical() + if err != nil { + return apperrors.Wrap(err, "failed to canonicalize audit log for signing") + } - auditLog.Signature = signature - auditLog.KekID = &kekID - auditLog.IsSigned = true + signature, kekID, err := a.keySigner.SignWithKey(canonical) + if err != nil { + return apperrors.Wrap(err, "failed to sign audit log") } - // Persist the audit log (signed or unsigned) + auditLog.Signature = signature + auditLog.KekID = &kekID + auditLog.IsSigned = true + if err = a.auditLogRepo.Create(ctx, auditLog); err != nil { return apperrors.Wrap(err, "failed to create audit log") } @@ -212,9 +204,9 @@ func (a *auditLogUseCase) VerifyBatch( } // NewAuditLogUseCase creates a new AuditLogUseCase with the provided dependencies. -// Pass nil for keySigner to create unsigned audit logs (legacy / testing mode). +// Pass keyring.NullSigner{} for tests that do not exercise signing behaviour. func NewAuditLogUseCase( - auditLogRepo AuditLogRepository, + auditLogRepo authDomain.AuditLogRepository, keySigner keyring.KeySigner, ) AuditLogUseCase { return &auditLogUseCase{ diff --git a/internal/auth/usecase/audit_log_usecase_test.go b/internal/auth/usecase/audit_log_usecase_test.go index d82495d..09fe022 100644 --- a/internal/auth/usecase/audit_log_usecase_test.go +++ b/internal/auth/usecase/audit_log_usecase_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/mock" authDomain "github.com/allisson/secrets/internal/auth/domain" + "github.com/allisson/secrets/internal/keyring" ) // mockAuditLogRepository is a mock implementation of AuditLogRepository for testing. @@ -94,7 +95,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Once() // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute err := useCase.Create(ctx, requestID, clientID, capability, path, metadata) @@ -133,7 +134,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Once() // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute with nil metadata err := useCase.Create(ctx, requestID, clientID, capability, path, nil) @@ -169,7 +170,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Times(3) // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute multiple times for i := 0; i < 3; i++ { @@ -223,7 +224,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Times(len(capabilities)) // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute for each capability for _, cap := range capabilities { @@ -256,7 +257,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Once() // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute err := useCase.Create(ctx, requestID, clientID, capability, path, metadata) @@ -288,7 +289,7 @@ func TestAuditLogUseCase_Create(t *testing.T) { Once() // Create use case - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) // Execute beforeCreate := time.Now().UTC() @@ -349,7 +350,7 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { Return(expectedCount, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) count, err := useCase.DeleteOlderThan(ctx, days, dryRun) @@ -369,7 +370,7 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { Return(expectedCount, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) count, err := useCase.DeleteOlderThan(ctx, days, dryRun) @@ -390,7 +391,7 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { Return(expectedCount, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) count, err := useCase.DeleteOlderThan(ctx, days, dryRun) @@ -420,7 +421,7 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { Return(tc.expectedCount, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) count, err := useCase.DeleteOlderThan(ctx, tc.days, tc.dryRun) @@ -442,7 +443,7 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { Return(int64(0), repositoryErr). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) count, err := useCase.DeleteOlderThan(ctx, days, dryRun) @@ -472,7 +473,7 @@ func TestAuditLogUseCase_ListCursor(t *testing.T) { Return(expectedLogs, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) logs, err := useCase.ListCursor(ctx, &afterID, limit, &from, &to, &clientID) @@ -488,7 +489,7 @@ func TestAuditLogUseCase_ListCursor(t *testing.T) { Return([]*authDomain.AuditLog{}, nil). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) logs, err := useCase.ListCursor(ctx, nil, 10, nil, nil, nil) @@ -504,7 +505,7 @@ func TestAuditLogUseCase_ListCursor(t *testing.T) { Return(nil, errors.New("db error")). Once() - useCase := NewAuditLogUseCase(mockRepo, nil) + useCase := NewAuditLogUseCase(mockRepo, keyring.NullSigner{}) logs, err := useCase.ListCursor(ctx, nil, 10, nil, nil, nil) diff --git a/internal/auth/usecase/client_usecase.go b/internal/auth/usecase/client_usecase.go index bc7dc53..d7ab211 100644 --- a/internal/auth/usecase/client_usecase.go +++ b/internal/auth/usecase/client_usecase.go @@ -15,8 +15,8 @@ import ( // clientUseCase implements ClientUseCase interface for managing client authentication. type clientUseCase struct { txManager database.TxManager - clientRepo ClientRepository - tokenRepo TokenRepository + clientRepo authDomain.ClientRepository + tokenRepo authDomain.TokenRepository auditLogUseCase AuditLogUseCase secretService authService.SecretService } @@ -218,8 +218,8 @@ func (c *clientUseCase) RotateSecret( // NewClientUseCase creates a new ClientUseCase with the provided dependencies. func NewClientUseCase( txManager database.TxManager, - clientRepo ClientRepository, - tokenRepo TokenRepository, + clientRepo authDomain.ClientRepository, + tokenRepo authDomain.TokenRepository, auditLogUseCase AuditLogUseCase, secretService authService.SecretService, ) ClientUseCase { diff --git a/internal/auth/usecase/interface.go b/internal/auth/usecase/interface.go index 3a09bfa..2d753a0 100644 --- a/internal/auth/usecase/interface.go +++ b/internal/auth/usecase/interface.go @@ -10,86 +10,6 @@ import ( authDomain "github.com/allisson/secrets/internal/auth/domain" ) -// ClientRepository defines persistence operations for authentication clients. -// Implementations must support transaction-aware operations via context propagation. -type ClientRepository interface { - // Create stores a new client in the repository. - Create(ctx context.Context, client *authDomain.Client) error - - // Update modifies an existing client in the repository. - Update(ctx context.Context, client *authDomain.Client) error - - // Get retrieves a client by ID. Returns ErrClientNotFound if not found. - Get(ctx context.Context, clientID uuid.UUID) (*authDomain.Client, error) - - // ListCursor retrieves clients ordered by ID descending (newest first) with cursor-based pagination. - // If afterID is provided, returns clients with ID less than afterID (DESC order). - // Returns empty slice if no clients found. Limit is pre-validated (1-1000). - ListCursor(ctx context.Context, afterID *uuid.UUID, limit int) ([]*authDomain.Client, error) - - // UpdateLockState atomically updates the failed attempt counter and lock expiry. - // Pass failedAttempts=0 and lockedUntil=nil to reset the lock on successful auth. - UpdateLockState(ctx context.Context, clientID uuid.UUID, failedAttempts int, lockedUntil *time.Time) error -} - -// TokenRepository defines persistence operations for authentication tokens. -// Implementations must support transaction-aware operations via context propagation. -type TokenRepository interface { - // Create stores a new token in the repository. - Create(ctx context.Context, token *authDomain.Token) error - - // Update modifies an existing token in the repository. - Update(ctx context.Context, token *authDomain.Token) error - - // Get retrieves a token by ID. Returns ErrTokenNotFound if not found. - Get(ctx context.Context, tokenID uuid.UUID) (*authDomain.Token, error) - - // GetByTokenHash retrieves a token by its SHA-256 hash value. - // Returns ErrTokenNotFound if no token matches the hash. - GetByTokenHash(ctx context.Context, tokenHash string) (*authDomain.Token, error) - - // RevokeByTokenID marks a specific token as revoked by setting its revoked_at timestamp. - RevokeByTokenID(ctx context.Context, tokenID uuid.UUID) error - - // RevokeByClientID marks all active tokens for a specific client as revoked. - RevokeByClientID(ctx context.Context, clientID uuid.UUID) error - - // PurgeExpiredAndRevoked permanently deletes tokens that are either expired or revoked - // and were created before the specified timestamp. Returns the number of deleted tokens. - PurgeExpiredAndRevoked(ctx context.Context, olderThan time.Time) (int64, error) -} - -// AuditLogRepository defines persistence operations for audit logs. -// Implementations must support transaction-aware operations via context propagation. -type AuditLogRepository interface { - // Create stores a new audit log entry recording an authorization decision. - // Returns error if the audit log ID already exists or database operation fails. - Create(ctx context.Context, auditLog *authDomain.AuditLog) error - - // Get retrieves a single audit log by ID. Returns error if not found. - // Used for signature verification of specific audit logs. - Get(ctx context.Context, id uuid.UUID) (*authDomain.AuditLog, error) - - // ListCursor retrieves audit logs ordered by created_at descending (newest first) with cursor-based pagination - // and optional time-based filtering. If afterID is provided, returns logs with ID greater than afterID (UUIDv7 ordering). - // Accepts createdAtFrom and createdAtTo as optional filters (nil means no filter). Both boundaries are inclusive (>= and <=). - // Accepts clientID as an optional filter (nil means no filter). - // All timestamps are expected in UTC. Returns empty slice if no audit logs found. Limit is pre-validated (1-1000). - ListCursor( - ctx context.Context, - afterID *uuid.UUID, - limit int, - createdAtFrom, createdAtTo *time.Time, - clientID *uuid.UUID, - ) ([]*authDomain.AuditLog, error) - - // DeleteOlderThan removes audit logs with created_at before the specified timestamp. - // When dryRun is true, returns count via SELECT COUNT(*) without deletion. When false, - // executes DELETE and returns affected rows. Supports transaction-aware operations via - // context propagation. All timestamps are expected in UTC. - DeleteOlderThan(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) -} - // ClientUseCase defines business logic operations for managing authentication clients. // It orchestrates client lifecycle including secret generation, policy management, // and soft deletion while maintaining audit history. diff --git a/internal/auth/usecase/token_usecase.go b/internal/auth/usecase/token_usecase.go index 43f1e23..be4e1de 100644 --- a/internal/auth/usecase/token_usecase.go +++ b/internal/auth/usecase/token_usecase.go @@ -18,8 +18,8 @@ import ( // tokenUseCase implements TokenUseCase interface for managing authentication tokens. type tokenUseCase struct { config *config.Config - clientRepo ClientRepository - tokenRepo TokenRepository + clientRepo authDomain.ClientRepository + tokenRepo authDomain.TokenRepository auditLogUseCase AuditLogUseCase secretService authService.SecretService tokenService authService.TokenService @@ -204,8 +204,8 @@ func (t *tokenUseCase) PurgeExpiredAndRevoked(ctx context.Context, days int) (co // NewTokenUseCase creates a new TokenUseCase with the provided dependencies. func NewTokenUseCase( config *config.Config, - clientRepo ClientRepository, - tokenRepo TokenRepository, + clientRepo authDomain.ClientRepository, + tokenRepo authDomain.TokenRepository, auditLogUseCase AuditLogUseCase, secretService authService.SecretService, tokenService authService.TokenService, diff --git a/internal/keyring/null_signer.go b/internal/keyring/null_signer.go new file mode 100644 index 0000000..f1f04fd --- /dev/null +++ b/internal/keyring/null_signer.go @@ -0,0 +1,13 @@ +package keyring + +import "github.com/google/uuid" + +// NullSigner is a no-op KeySigner for tests that do not exercise signing behaviour. +// SignWithKey returns a 32-byte zero signature and uuid.Nil; VerifyWithKey always returns nil. +type NullSigner struct{} + +func (NullSigner) SignWithKey(_ []byte) ([]byte, uuid.UUID, error) { + return make([]byte, 32), uuid.Nil, nil +} + +func (NullSigner) VerifyWithKey(_ uuid.UUID, _, _ []byte) error { return nil } diff --git a/internal/secrets/domain/repository.go b/internal/secrets/domain/repository.go new file mode 100644 index 0000000..29486ba --- /dev/null +++ b/internal/secrets/domain/repository.go @@ -0,0 +1,33 @@ +package domain + +import ( + "context" + "time" +) + +// SecretRepository defines the interface for Secret persistence operations. +type SecretRepository interface { + // Create stores a new secret in the repository using transaction support from context. + Create(ctx context.Context, secret *Secret) error + + // Delete soft deletes all versions of a secret by path, marking them with DeletedAt timestamp. + Delete(ctx context.Context, path string) error + + // GetByPath retrieves the latest version of a secret by its path. Returns ErrSecretNotFound if not found. + GetByPath(ctx context.Context, path string) (*Secret, error) + + // GetByPathAndVersion retrieves a specific version of a secret. Returns ErrSecretNotFound if not found. + GetByPathAndVersion(ctx context.Context, path string, version uint) (*Secret, error) + + // ListCursor retrieves secrets ordered by path ascending with cursor-based pagination. + // If afterPath is provided, returns secrets with path greater than afterPath (ASC order). + // Returns the latest version for each secret. Filters out soft-deleted secrets. + // Returns empty slice if no secrets found. Limit is pre-validated (1-1000). + ListCursor(ctx context.Context, afterPath *string, limit int) ([]*Secret, error) + + // HardDelete permanently removes soft-deleted secrets older than the specified time. + // Only affects secrets where deleted_at IS NOT NULL. + // If dryRun is true, returns count without performing deletion. + // Returns the number of secrets that were (or would be) deleted. + HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) +} diff --git a/internal/secrets/usecase/interface.go b/internal/secrets/usecase/interface.go index 1f34760..350ad5b 100644 --- a/internal/secrets/usecase/interface.go +++ b/internal/secrets/usecase/interface.go @@ -5,38 +5,10 @@ package usecase import ( "context" - "time" secretsDomain "github.com/allisson/secrets/internal/secrets/domain" ) -// SecretRepository defines the interface for Secret persistence operations. -type SecretRepository interface { - // Create stores a new secret in the repository using transaction support from context. - Create(ctx context.Context, secret *secretsDomain.Secret) error - - // Delete soft deletes all versions of a secret by path, marking them with DeletedAt timestamp. - Delete(ctx context.Context, path string) error - - // GetByPath retrieves the latest version of a secret by its path. Returns ErrSecretNotFound if not found. - GetByPath(ctx context.Context, path string) (*secretsDomain.Secret, error) - - // GetByPathAndVersion retrieves a specific version of a secret. Returns ErrSecretNotFound if not found. - GetByPathAndVersion(ctx context.Context, path string, version uint) (*secretsDomain.Secret, error) - - // ListCursor retrieves secrets ordered by path ascending with cursor-based pagination. - // If afterPath is provided, returns secrets with path greater than afterPath (ASC order). - // Returns the latest version for each secret. Filters out soft-deleted secrets. - // Returns empty slice if no secrets found. Limit is pre-validated (1-1000). - ListCursor(ctx context.Context, afterPath *string, limit int) ([]*secretsDomain.Secret, error) - - // HardDelete permanently removes soft-deleted secrets older than the specified time. - // Only affects secrets where deleted_at IS NOT NULL. - // If dryRun is true, returns count without performing deletion. - // Returns the number of secrets that were (or would be) deleted. - HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) -} - // SecretUseCase defines the interface for secret management business logic. type SecretUseCase interface { // CreateOrUpdate creates a new secret or increments the version if path exists. diff --git a/internal/secrets/usecase/secret_usecase.go b/internal/secrets/usecase/secret_usecase.go index a1553a7..2f32704 100644 --- a/internal/secrets/usecase/secret_usecase.go +++ b/internal/secrets/usecase/secret_usecase.go @@ -17,7 +17,7 @@ import ( type secretUseCase struct { txManager database.TxManager keyring keyring.Keyring - secretRepo SecretRepository + secretRepo secretsDomain.SecretRepository secretValueSizeLimit int } @@ -148,7 +148,7 @@ func (s *secretUseCase) PurgeDeleted( func NewSecretUseCase( txManager database.TxManager, kr keyring.Keyring, - secretRepo SecretRepository, + secretRepo secretsDomain.SecretRepository, secretValueSizeLimit int, ) SecretUseCase { return &secretUseCase{ diff --git a/internal/tokenization/domain/repository.go b/internal/tokenization/domain/repository.go new file mode 100644 index 0000000..aff43fb --- /dev/null +++ b/internal/tokenization/domain/repository.go @@ -0,0 +1,58 @@ +package domain + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// TokenizationKeyRepository defines the interface for tokenization key persistence. +type TokenizationKeyRepository interface { + Create(ctx context.Context, key *TokenizationKey) error + Delete(ctx context.Context, name string) error + Get(ctx context.Context, keyID uuid.UUID) (*TokenizationKey, error) + GetByName(ctx context.Context, name string) (*TokenizationKey, error) + GetByNameAndVersion( + ctx context.Context, + name string, + version uint, + ) (*TokenizationKey, error) + + // ListCursor retrieves tokenization keys ordered by name ascending with cursor-based pagination. + // If afterName is provided, returns keys with name greater than afterName (ASC order). + // Returns the latest version for each key. Filters out soft-deleted keys. + // Returns empty slice if no keys found. Limit is pre-validated (1-1000). + ListCursor( + ctx context.Context, + afterName *string, + limit int, + ) ([]*TokenizationKey, error) + + // HardDelete permanently removes soft-deleted tokenization keys older than the specified time. + // It must also cascade the deletion to any associated tokens in the tokenization_tokens table. + // Only affects keys where deleted_at IS NOT NULL. + // If dryRun is true, returns count of keys without performing deletion. + // Returns the number of keys that were (or would be) deleted. + HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) +} + +// TokenRepository defines the interface for token mapping persistence. +type TokenRepository interface { + Create(ctx context.Context, token *Token) error + CreateBatch(ctx context.Context, tokens []*Token) error + GetByToken(ctx context.Context, token string) (*Token, error) + GetBatchByTokens(ctx context.Context, tokens []string) ([]*Token, error) + GetByValueHash(ctx context.Context, keyID uuid.UUID, valueHash string) (*Token, error) + Revoke(ctx context.Context, token string) error + + // DeleteExpired deletes tokens that expired before the specified timestamp. + // Returns the number of deleted tokens. Uses transaction support via database.GetTx(). + // All timestamps are expected in UTC. + DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) + + // CountExpired counts tokens that expired before the specified timestamp without deleting them. + // Returns the count of matching tokens. Uses transaction support via database.GetTx(). + // All timestamps are expected in UTC. + CountExpired(ctx context.Context, olderThan time.Time) (int64, error) +} diff --git a/internal/tokenization/usecase/hash_service.go b/internal/tokenization/usecase/hash_service.go deleted file mode 100644 index 28a8f89..0000000 --- a/internal/tokenization/usecase/hash_service.go +++ /dev/null @@ -1,33 +0,0 @@ -package usecase - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" -) - -// HashService provides cryptographic hashing for deterministic token lookups. -type HashService interface { - Hash(value []byte, salt []byte) string -} - -type sha256HashService struct{} - -// NewSHA256HashService creates a new SHA-256 hash service. -func NewSHA256HashService() HashService { - return &sha256HashService{} -} - -// Hash computes the hash of the input value. -// If salt is provided, it uses HMAC-SHA256 with the salt as the key. -// If salt is empty, it falls back to simple SHA-256 for backward compatibility. -func (s *sha256HashService) Hash(value []byte, salt []byte) string { - if len(salt) == 0 { - hash := sha256.Sum256(value) - return hex.EncodeToString(hash[:]) - } - - h := hmac.New(sha256.New, salt) - h.Write(value) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/internal/tokenization/usecase/hash_service_test.go b/internal/tokenization/usecase/hash_service_test.go deleted file mode 100644 index 3d28a82..0000000 --- a/internal/tokenization/usecase/hash_service_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package usecase - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestNewSHA256HashService tests the constructor. -func TestNewSHA256HashService(t *testing.T) { - hashService := NewSHA256HashService() - assert.NotNil(t, hashService) - assert.IsType(t, &sha256HashService{}, hashService) -} - -// TestSHA256HashService_Hash tests the Hash method. -func TestSHA256HashService_Hash(t *testing.T) { - hashService := NewSHA256HashService() - - t.Run("Success_HashEmptyInputNoSalt", func(t *testing.T) { - // Empty input should produce the SHA-256 hash of empty string when salt is empty - input := []byte{} - result := hashService.Hash(input, nil) - - // Verify result is non-empty and valid hex - assert.NotEmpty(t, result) - assert.Equal(t, 64, len(result)) // SHA-256 produces 32 bytes = 64 hex chars - - // Verify it matches expected SHA-256 hash of empty string - expectedHash := sha256.Sum256([]byte{}) - expected := hex.EncodeToString(expectedHash[:]) - assert.Equal(t, expected, result) - }) - - t.Run("Success_HashWithSalt", func(t *testing.T) { - input := []byte("hello") - salt := []byte("secret-salt") - result := hashService.Hash(input, salt) - - // Verify result is non-empty and valid hex - assert.NotEmpty(t, result) - assert.Equal(t, 64, len(result)) - - // Verify it matches expected HMAC-SHA256 hash - h := hmac.New(sha256.New, salt) - h.Write(input) - expected := hex.EncodeToString(h.Sum(nil)) - assert.Equal(t, expected, result) - - // Verify it is different from unsalted hash - unsaltedHash := sha256.Sum256(input) - unsalted := hex.EncodeToString(unsaltedHash[:]) - assert.NotEqual(t, unsalted, result) - }) - - t.Run("Success_DifferentSaltsProduceDifferentHashes", func(t *testing.T) { - input := []byte("same-plaintext") - salt1 := []byte("salt-1") - salt2 := []byte("salt-2") - - result1 := hashService.Hash(input, salt1) - result2 := hashService.Hash(input, salt2) - - assert.NotEqual(t, result1, result2) - }) - - t.Run("Success_HashLargeInputWithSalt", func(t *testing.T) { - // Create a large input (10KB) - input := []byte(strings.Repeat("A", 10240)) - salt := []byte("large-input-salt") - result := hashService.Hash(input, salt) - - // Verify result is non-empty and valid hex - assert.NotEmpty(t, result) - assert.Equal(t, 64, len(result)) - - // Verify it matches expected HMAC-SHA256 hash - h := hmac.New(sha256.New, salt) - h.Write(input) - expected := hex.EncodeToString(h.Sum(nil)) - assert.Equal(t, expected, result) - }) - - t.Run("Success_HashBinaryDataWithSalt", func(t *testing.T) { - // Test with binary data (not just ASCII) - input := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD} - salt := []byte{0xAA, 0xBB, 0xCC} - result := hashService.Hash(input, salt) - - // Verify result is non-empty and valid hex - assert.NotEmpty(t, result) - assert.Equal(t, 64, len(result)) - - // Verify it matches expected HMAC-SHA256 hash - h := hmac.New(sha256.New, salt) - h.Write(input) - expected := hex.EncodeToString(h.Sum(nil)) - assert.Equal(t, expected, result) - }) - - t.Run("Success_ConsistencyCheck", func(t *testing.T) { - // Same input and salt should always produce the same hash - input := []byte("test-consistency") - salt := []byte("test-salt") - result1 := hashService.Hash(input, salt) - result2 := hashService.Hash(input, salt) - result3 := hashService.Hash(input, salt) - - assert.Equal(t, result1, result2) - assert.Equal(t, result2, result3) - }) - - t.Run("Success_KnownTestVectorHMAC", func(t *testing.T) { - // Test with a known HMAC-SHA256 test vector - // HMAC_SHA256(key="key", data="The quick brown fox jumps over the lazy dog") - // = f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 - input := []byte("The quick brown fox jumps over the lazy dog") - salt := []byte("key") - result := hashService.Hash(input, salt) - - expected := "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" - assert.Equal(t, expected, result) - }) - - t.Run("Success_UnicodeInputWithSalt", func(t *testing.T) { - // Test with Unicode characters - input := []byte("Hello δΈ–η•Œ 🌍") - salt := []byte("unicode-salt-πŸš€") - result := hashService.Hash(input, salt) - - // Verify result is non-empty and valid hex - assert.NotEmpty(t, result) - assert.Equal(t, 64, len(result)) - - // Verify consistency - result2 := hashService.Hash(input, salt) - assert.Equal(t, result, result2) - }) -} diff --git a/internal/tokenization/usecase/interface.go b/internal/tokenization/usecase/interface.go index 65818fd..c2419cd 100644 --- a/internal/tokenization/usecase/interface.go +++ b/internal/tokenization/usecase/interface.go @@ -6,62 +6,10 @@ import ( "context" "time" - "github.com/google/uuid" - "github.com/allisson/secrets/internal/keyring" tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" ) -// TokenizationKeyRepository defines the interface for tokenization key persistence. -type TokenizationKeyRepository interface { - Create(ctx context.Context, key *tokenizationDomain.TokenizationKey) error - Delete(ctx context.Context, name string) error - Get(ctx context.Context, keyID uuid.UUID) (*tokenizationDomain.TokenizationKey, error) - GetByName(ctx context.Context, name string) (*tokenizationDomain.TokenizationKey, error) - GetByNameAndVersion( - ctx context.Context, - name string, - version uint, - ) (*tokenizationDomain.TokenizationKey, error) - - // ListCursor retrieves tokenization keys ordered by name ascending with cursor-based pagination. - // If afterName is provided, returns keys with name greater than afterName (ASC order). - // Returns the latest version for each key. Filters out soft-deleted keys. - // Returns empty slice if no keys found. Limit is pre-validated (1-1000). - ListCursor( - ctx context.Context, - afterName *string, - limit int, - ) ([]*tokenizationDomain.TokenizationKey, error) - - // HardDelete permanently removes soft-deleted tokenization keys older than the specified time. - // It must also cascade the deletion to any associated tokens in the tokenization_tokens table. - // Only affects keys where deleted_at IS NOT NULL. - // If dryRun is true, returns count of keys without performing deletion. - // Returns the number of keys that were (or would be) deleted. - HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) -} - -// TokenRepository defines the interface for token mapping persistence. -type TokenRepository interface { - Create(ctx context.Context, token *tokenizationDomain.Token) error - CreateBatch(ctx context.Context, tokens []*tokenizationDomain.Token) error - GetByToken(ctx context.Context, token string) (*tokenizationDomain.Token, error) - GetBatchByTokens(ctx context.Context, tokens []string) ([]*tokenizationDomain.Token, error) - GetByValueHash(ctx context.Context, keyID uuid.UUID, valueHash string) (*tokenizationDomain.Token, error) - Revoke(ctx context.Context, token string) error - - // DeleteExpired deletes tokens that expired before the specified timestamp. - // Returns the number of deleted tokens. Uses transaction support via database.GetTx(). - // All timestamps are expected in UTC. - DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) - - // CountExpired counts tokens that expired before the specified timestamp without deleting them. - // Returns the count of matching tokens. Uses transaction support via database.GetTx(). - // All timestamps are expected in UTC. - CountExpired(ctx context.Context, olderThan time.Time) (int64, error) -} - // TokenizationKeyUseCase defines the interface for tokenization key management operations. type TokenizationKeyUseCase interface { // Create generates a new tokenization key with version 1 and an associated DEK. diff --git a/internal/tokenization/usecase/mocks/mocks.go b/internal/tokenization/usecase/mocks/mocks.go index 14e5198..772ae67 100644 --- a/internal/tokenization/usecase/mocks/mocks.go +++ b/internal/tokenization/usecase/mocks/mocks.go @@ -14,89 +14,6 @@ import ( mock "github.com/stretchr/testify/mock" ) -// NewMockHashService creates a new instance of MockHashService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockHashService(t interface { - mock.TestingT - Cleanup(func()) -}) *MockHashService { - mock := &MockHashService{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockHashService is an autogenerated mock type for the HashService type -type MockHashService struct { - mock.Mock -} - -type MockHashService_Expecter struct { - mock *mock.Mock -} - -func (_m *MockHashService) EXPECT() *MockHashService_Expecter { - return &MockHashService_Expecter{mock: &_m.Mock} -} - -// Hash provides a mock function for the type MockHashService -func (_mock *MockHashService) Hash(value []byte, salt []byte) string { - ret := _mock.Called(value, salt) - - if len(ret) == 0 { - panic("no return value specified for Hash") - } - - var r0 string - if returnFunc, ok := ret.Get(0).(func([]byte, []byte) string); ok { - r0 = returnFunc(value, salt) - } else { - r0 = ret.Get(0).(string) - } - return r0 -} - -// MockHashService_Hash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hash' -type MockHashService_Hash_Call struct { - *mock.Call -} - -// Hash is a helper method to define mock.On call -// - value []byte -// - salt []byte -func (_e *MockHashService_Expecter) Hash(value interface{}, salt interface{}) *MockHashService_Hash_Call { - return &MockHashService_Hash_Call{Call: _e.mock.On("Hash", value, salt)} -} - -func (_c *MockHashService_Hash_Call) Run(run func(value []byte, salt []byte)) *MockHashService_Hash_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 []byte - if args[0] != nil { - arg0 = args[0].([]byte) - } - var arg1 []byte - if args[1] != nil { - arg1 = args[1].([]byte) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockHashService_Hash_Call) Return(s string) *MockHashService_Hash_Call { - _c.Call.Return(s) - return _c -} - -func (_c *MockHashService_Hash_Call) RunAndReturn(run func(value []byte, salt []byte) string) *MockHashService_Hash_Call { - _c.Call.Return(run) - return _c -} // NewMockTokenizationKeyRepository creates a new instance of MockTokenizationKeyRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. diff --git a/internal/tokenization/usecase/tokenization_key_usecase.go b/internal/tokenization/usecase/tokenization_key_usecase.go index 6c58e8e..0dd0f43 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase.go +++ b/internal/tokenization/usecase/tokenization_key_usecase.go @@ -16,7 +16,7 @@ import ( // tokenizationKeyUseCase implements TokenizationKeyUseCase for managing tokenization keys. type tokenizationKeyUseCase struct { txManager database.TxManager - tokenizationKeyRepo TokenizationKeyRepository + tokenizationKeyRepo tokenizationDomain.TokenizationKeyRepository keyring keyring.Keyring } @@ -193,7 +193,7 @@ func (t *tokenizationKeyUseCase) PurgeDeleted( // NewTokenizationKeyUseCase creates a new tokenization key use case instance. func NewTokenizationKeyUseCase( txManager database.TxManager, - tokenizationKeyRepo TokenizationKeyRepository, + tokenizationKeyRepo tokenizationDomain.TokenizationKeyRepository, kr keyring.Keyring, ) TokenizationKeyUseCase { return &tokenizationKeyUseCase{ diff --git a/internal/tokenization/usecase/tokenization_usecase.go b/internal/tokenization/usecase/tokenization_usecase.go index 0932ff9..bc1ff63 100644 --- a/internal/tokenization/usecase/tokenization_usecase.go +++ b/internal/tokenization/usecase/tokenization_usecase.go @@ -7,6 +7,9 @@ package usecase import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "time" "github.com/google/uuid" @@ -18,6 +21,18 @@ import ( tokenizationService "github.com/allisson/secrets/internal/tokenization/service" ) +// tokenValueHash computes HMAC-SHA256(value, salt) as a hex string. +// Falls back to plain SHA-256 when salt is empty (legacy path). +func tokenValueHash(value, salt []byte) string { + if len(salt) == 0 { + h := sha256.Sum256(value) + return hex.EncodeToString(h[:]) + } + mac := hmac.New(sha256.New, salt) + mac.Write(value) + return hex.EncodeToString(mac.Sum(nil)) +} + // validateTokenLength checks if the plaintext length is valid for the token format type. func validateTokenLength(formatType tokenizationDomain.FormatType, length int) error { if formatType == tokenizationDomain.FormatUUID { @@ -39,9 +54,8 @@ func validateTokenLength(formatType tokenizationDomain.FormatType, length int) e // tokenizationUseCase implements TokenizationUseCase for managing tokenization operations. type tokenizationUseCase struct { txManager database.TxManager - tokenizationRepo TokenizationKeyRepository - tokenRepo TokenRepository - hashService HashService + tokenizationRepo tokenizationDomain.TokenizationKeyRepository + tokenRepo tokenizationDomain.TokenRepository keyring keyring.Keyring } @@ -67,7 +81,7 @@ func (t *tokenizationUseCase) Tokenize( // In deterministic mode, look up an existing token before encrypting. if tokenizationKey.IsDeterministic { - valueHash := t.hashService.Hash(plaintext, tokenizationKey.Salt) + valueHash := tokenValueHash(plaintext, tokenizationKey.Salt) existingToken, err := t.tokenRepo.GetByValueHash(ctx, tokenizationKey.ID, valueHash) if err != nil && !apperrors.Is(err, tokenizationDomain.ErrTokenNotFound) { return nil, apperrors.Wrap(err, "failed to check existing token in deterministic mode") @@ -117,7 +131,7 @@ func (t *tokenizationUseCase) Tokenize( } if tokenizationKey.IsDeterministic { - valueHash := t.hashService.Hash(plaintext, tokenizationKey.Salt) + valueHash := tokenValueHash(plaintext, tokenizationKey.Salt) token.ValueHash = &valueHash } @@ -125,7 +139,7 @@ func (t *tokenizationUseCase) Tokenize( // Race: another goroutine inserted the deterministic token between the // existence check and the insert. Re-read and return that one. if tokenizationKey.IsDeterministic && apperrors.Is(err, apperrors.ErrConflict) { - valueHash := t.hashService.Hash(plaintext, tokenizationKey.Salt) + valueHash := tokenValueHash(plaintext, tokenizationKey.Salt) existingToken, queryErr := t.tokenRepo.GetByValueHash(ctx, tokenizationKey.ID, valueHash) if queryErr != nil { return nil, apperrors.Wrap(err, "failed to create token") @@ -272,16 +286,14 @@ func (t *tokenizationUseCase) CleanupExpired( // NewTokenizationUseCase creates a new TokenizationUseCase backed by a Keyring. func NewTokenizationUseCase( txManager database.TxManager, - tokenizationRepo TokenizationKeyRepository, - tokenRepo TokenRepository, - hashService HashService, + tokenizationRepo tokenizationDomain.TokenizationKeyRepository, + tokenRepo tokenizationDomain.TokenRepository, kr keyring.Keyring, ) TokenizationUseCase { return &tokenizationUseCase{ txManager: txManager, tokenizationRepo: tokenizationRepo, tokenRepo: tokenRepo, - hashService: hashService, keyring: kr, } } diff --git a/internal/tokenization/usecase/tokenization_usecase_test.go b/internal/tokenization/usecase/tokenization_usecase_test.go index 62e870c..3023936 100644 --- a/internal/tokenization/usecase/tokenization_usecase_test.go +++ b/internal/tokenization/usecase/tokenization_usecase_test.go @@ -2,6 +2,9 @@ package usecase_test import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "testing" "time" @@ -24,15 +27,13 @@ func newTokenizationUseCase( *keyring.Fake, *mocks.MockTokenizationKeyRepository, *mocks.MockTokenRepository, - *mocks.MockHashService, ) { t.Helper() fake := keyring.NewFake() keyRepo := mocks.NewMockTokenizationKeyRepository(t) tokenRepo := mocks.NewMockTokenRepository(t) - hashSvc := mocks.NewMockHashService(t) - uc := usecase.NewTokenizationUseCase(noopTxManager{}, keyRepo, tokenRepo, hashSvc, fake) - return uc, fake, keyRepo, tokenRepo, hashSvc + uc := usecase.NewTokenizationUseCase(noopTxManager{}, keyRepo, tokenRepo, fake) + return uc, fake, keyRepo, tokenRepo } // allocateDekForTest seeds the keyring Fake with a DekID and returns it, @@ -50,7 +51,7 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { t.Run("Success_NonDeterministic", func(t *testing.T) { t.Parallel() - uc, fake, keyRepo, tokenRepo, _ := newTokenizationUseCase(t) + uc, fake, keyRepo, tokenRepo := newTokenizationUseCase(t) dekID := allocateDekForTest(t, fake) key := &tokenizationDomain.TokenizationKey{ @@ -76,7 +77,7 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { t.Run("Success_Deterministic_ReturnsExistingValidToken", func(t *testing.T) { t.Parallel() - uc, fake, keyRepo, tokenRepo, hashSvc := newTokenizationUseCase(t) + uc, fake, keyRepo, tokenRepo := newTokenizationUseCase(t) dekID := allocateDekForTest(t, fake) key := &tokenizationDomain.TokenizationKey{ @@ -94,10 +95,13 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { Token: "existing-token", } + mac := hmac.New(sha256.New, []byte("salt")) + mac.Write([]byte("payload")) + expectedHash := hex.EncodeToString(mac.Sum(nil)) + keyRepo.EXPECT().GetByName(ctx, "k").Return(key, nil) - hashSvc.EXPECT().Hash([]byte("payload"), []byte("salt")).Return("hash-value") tokenRepo.EXPECT(). - GetByValueHash(ctx, key.ID, "hash-value"). + GetByValueHash(ctx, key.ID, expectedHash). Return(existing, nil) got, err := uc.Tokenize(ctx, "k", []byte("payload"), nil, nil) @@ -107,14 +111,14 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { t.Run("Error_PlaintextEmpty", func(t *testing.T) { t.Parallel() - uc, _, _, _, _ := newTokenizationUseCase(t) + uc, _, _, _ := newTokenizationUseCase(t) _, err := uc.Tokenize(ctx, "k", nil, nil, nil) assert.ErrorIs(t, err, tokenizationDomain.ErrPlaintextEmpty) }) t.Run("Error_PlaintextTooLarge", func(t *testing.T) { t.Parallel() - uc, _, _, _, _ := newTokenizationUseCase(t) + uc, _, _, _ := newTokenizationUseCase(t) big := make([]byte, tokenizationDomain.MaxPlaintextSize+1) _, err := uc.Tokenize(ctx, "k", big, nil, nil) assert.ErrorIs(t, err, tokenizationDomain.ErrPlaintextTooLarge) @@ -122,7 +126,7 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { t.Run("Error_KeyNotFound", func(t *testing.T) { t.Parallel() - uc, _, keyRepo, _, _ := newTokenizationUseCase(t) + uc, _, keyRepo, _ := newTokenizationUseCase(t) keyRepo.EXPECT(). GetByName(ctx, "missing"). Return(nil, tokenizationDomain.ErrTokenizationKeyNotFound) @@ -133,7 +137,7 @@ func TestTokenizationUseCase_Tokenize(t *testing.T) { t.Run("Error_KeyringEncryptFails", func(t *testing.T) { t.Parallel() - uc, fake, keyRepo, _, _ := newTokenizationUseCase(t) + uc, fake, keyRepo, _ := newTokenizationUseCase(t) dekID := allocateDekForTest(t, fake) fake.FailEncrypt = apperrors.New("boom") @@ -154,7 +158,7 @@ func TestTokenizationUseCase_Detokenize(t *testing.T) { t.Run("Success_RoundTrip", func(t *testing.T) { t.Parallel() - uc, fake, keyRepo, tokenRepo, _ := newTokenizationUseCase(t) + uc, fake, keyRepo, tokenRepo := newTokenizationUseCase(t) // Encrypt via the fake to get matching ciphertext/nonce/dekID. handle, err := fake.AllocateDek(ctx, keyring.AESGCM) @@ -186,7 +190,7 @@ func TestTokenizationUseCase_Detokenize(t *testing.T) { t.Run("Error_TokenExpired", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) past := time.Now().Add(-time.Hour) tokenRepo.EXPECT().GetByToken(ctx, "tok").Return(&tokenizationDomain.Token{ Token: "tok", @@ -199,7 +203,7 @@ func TestTokenizationUseCase_Detokenize(t *testing.T) { t.Run("Error_TokenRevoked", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) now := time.Now() tokenRepo.EXPECT().GetByToken(ctx, "tok").Return(&tokenizationDomain.Token{ Token: "tok", @@ -212,7 +216,7 @@ func TestTokenizationUseCase_Detokenize(t *testing.T) { t.Run("Error_DecryptFails", func(t *testing.T) { t.Parallel() - uc, fake, keyRepo, tokenRepo, _ := newTokenizationUseCase(t) + uc, fake, keyRepo, tokenRepo := newTokenizationUseCase(t) dekID := allocateDekForTest(t, fake) fake.FailDecrypt = apperrors.New("AEAD tag mismatch") @@ -237,7 +241,7 @@ func TestTokenizationUseCase_Validate(t *testing.T) { t.Run("Valid", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) tokenRepo.EXPECT().GetByToken(ctx, "tok").Return(&tokenizationDomain.Token{ Token: "tok", CreatedAt: time.Now(), @@ -250,7 +254,7 @@ func TestTokenizationUseCase_Validate(t *testing.T) { t.Run("NotFound_ReturnsFalseNoError", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) tokenRepo.EXPECT().GetByToken(ctx, "tok").Return(nil, tokenizationDomain.ErrTokenNotFound) ok, err := uc.Validate(ctx, "tok") @@ -263,7 +267,7 @@ func TestTokenizationUseCase_Revoke(t *testing.T) { t.Parallel() ctx := context.Background() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) tokenRepo.EXPECT().GetByToken(ctx, "tok").Return(&tokenizationDomain.Token{Token: "tok"}, nil) tokenRepo.EXPECT().Revoke(ctx, "tok").Return(nil) @@ -276,14 +280,14 @@ func TestTokenizationUseCase_CleanupExpired(t *testing.T) { t.Run("Error_NegativeDays", func(t *testing.T) { t.Parallel() - uc, _, _, _, _ := newTokenizationUseCase(t) + uc, _, _, _ := newTokenizationUseCase(t) _, err := uc.CleanupExpired(ctx, -1, false) assert.Error(t, err) }) t.Run("Success_DryRun", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) tokenRepo.EXPECT().CountExpired(ctx, mock.Anything).Return(int64(7), nil) n, err := uc.CleanupExpired(ctx, 30, true) @@ -293,7 +297,7 @@ func TestTokenizationUseCase_CleanupExpired(t *testing.T) { t.Run("Success_Delete", func(t *testing.T) { t.Parallel() - uc, _, _, tokenRepo, _ := newTokenizationUseCase(t) + uc, _, _, tokenRepo := newTokenizationUseCase(t) tokenRepo.EXPECT().DeleteExpired(ctx, mock.Anything).Return(int64(4), nil) n, err := uc.CleanupExpired(ctx, 30, false) diff --git a/internal/transit/usecase/interface.go b/internal/transit/usecase/interface.go index b2ac410..be990d7 100644 --- a/internal/transit/usecase/interface.go +++ b/internal/transit/usecase/interface.go @@ -9,10 +9,6 @@ import ( transitDomain "github.com/allisson/secrets/internal/transit/domain" ) -// TransitKeyRepository is re-exported for convenience. The canonical location -// is internal/transit/domain/repository.go. -type TransitKeyRepository = transitDomain.TransitKeyRepository - // TransitKeyUseCase defines the interface for transit encryption operations. type TransitKeyUseCase interface { // Create generates a new transit key with version 1 and an associated DEK for encryption. diff --git a/internal/transit/usecase/transit_key_usecase.go b/internal/transit/usecase/transit_key_usecase.go index 0074506..ba45f19 100644 --- a/internal/transit/usecase/transit_key_usecase.go +++ b/internal/transit/usecase/transit_key_usecase.go @@ -19,7 +19,7 @@ import ( // transitKeyUseCase implements TransitKeyUseCase for managing transit keys. type transitKeyUseCase struct { txManager database.TxManager - transitRepo TransitKeyRepository + transitRepo transitDomain.TransitKeyRepository keyring keyring.Keyring } @@ -202,7 +202,7 @@ func (t *transitKeyUseCase) PurgeDeleted( // NewTransitKeyUseCase creates a new TransitKeyUseCase backed by a Keyring. func NewTransitKeyUseCase( txManager database.TxManager, - transitRepo TransitKeyRepository, + transitRepo transitDomain.TransitKeyRepository, kr keyring.Keyring, ) TransitKeyUseCase { return &transitKeyUseCase{ diff --git a/test/integration/audit_log_signature_test.go b/test/integration/audit_log_signature_test.go index 3505866..75e0d6b 100644 --- a/test/integration/audit_log_signature_test.go +++ b/test/integration/audit_log_signature_test.go @@ -244,36 +244,26 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { }) t.Run("LegacyUnsignedLogs", func(t *testing.T) { - // Create an unsigned legacy audit log (no signer) - legacyUseCase := authUseCase.NewAuditLogUseCase(auditLogRepo, nil) - - requestID := uuid.Must(uuid.NewV7()) - clientID := testCtx.rootClient.ID - - err := legacyUseCase.Create( - ctx, - requestID, - clientID, - authDomain.ReadCapability, - "/api/v1/secrets/legacy", - nil, - ) + // Insert an unsigned legacy log directly, simulating pre-signing-era data. + legacyLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: testCtx.rootClient.ID, + Capability: authDomain.ReadCapability, + Path: "/api/v1/secrets/legacy", + CreatedAt: time.Now().UTC().Truncate(time.Microsecond), + IsSigned: false, + } + err := auditLogRepo.Create(ctx, legacyLog) require.NoError(t, err, "failed to create legacy audit log") - // Retrieve the log - logs, err := legacyUseCase.ListCursor(ctx, nil, 1, nil, nil, nil) - require.NoError(t, err, "failed to list audit logs") - require.Len(t, logs, 1, "expected exactly one audit log") - - log := logs[0] - // Verify it's unsigned - assert.False(t, log.IsSigned, "audit log should not be signed") - assert.Nil(t, log.KekID, "kek_id should be nil") - assert.Empty(t, log.Signature, "signature should be empty") + assert.False(t, legacyLog.IsSigned, "audit log should not be signed") + assert.Nil(t, legacyLog.KekID, "kek_id should be nil") + assert.Empty(t, legacyLog.Signature, "signature should be empty") // Verification should return ErrSignatureMissing - err = auditLogUseCase.VerifyIntegrity(ctx, log.ID) + err = auditLogUseCase.VerifyIntegrity(ctx, legacyLog.ID) assert.Error(t, err, "verification should fail for unsigned log") assert.ErrorIs(t, err, authDomain.ErrSignatureMissing, "error should be ErrSignatureMissing") }) @@ -298,18 +288,18 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { time.Sleep(10 * time.Millisecond) } - // Create 2 unsigned legacy logs - legacyUseCase := authUseCase.NewAuditLogUseCase(auditLogRepo, nil) + // Insert 2 unsigned legacy logs directly, simulating pre-signing-era data. for i := 0; i < 2; i++ { - requestID := uuid.Must(uuid.NewV7()) - err := legacyUseCase.Create( - ctx, - requestID, - clientID, - authDomain.WriteCapability, - "/legacy", - nil, - ) + legacyLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID, + Capability: authDomain.WriteCapability, + Path: "/legacy", + CreatedAt: time.Now().UTC().Truncate(time.Microsecond), + IsSigned: false, + } + err := auditLogRepo.Create(ctx, legacyLog) require.NoError(t, err) time.Sleep(10 * time.Millisecond) }