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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions internal/app/di.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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]
Expand Down
19 changes: 10 additions & 9 deletions internal/app/di_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
})
}
Expand All @@ -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)
})
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions internal/app/di_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand All @@ -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)
Expand Down
16 changes: 7 additions & 9 deletions internal/app/di_tokenization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,24 @@ 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"
)

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)
})
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 4 additions & 3 deletions internal/app/di_transit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand All @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions internal/auth/domain/repository.go
Original file line number Diff line number Diff line change
@@ -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)
}
44 changes: 18 additions & 26 deletions internal/auth/usecase/audit_log_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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")
}
Expand Down Expand Up @@ -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{
Expand Down
Loading