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) }