diff --git a/conductor/archive/audit_log_filtering_by_client_20260307/index.md b/conductor/archive/audit_log_filtering_by_client_20260307/index.md new file mode 100644 index 0000000..3ed9226 --- /dev/null +++ b/conductor/archive/audit_log_filtering_by_client_20260307/index.md @@ -0,0 +1,5 @@ +# Track audit_log_filtering_by_client_20260307 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) diff --git a/conductor/archive/audit_log_filtering_by_client_20260307/metadata.json b/conductor/archive/audit_log_filtering_by_client_20260307/metadata.json new file mode 100644 index 0000000..464c419 --- /dev/null +++ b/conductor/archive/audit_log_filtering_by_client_20260307/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "audit_log_filtering_by_client_20260307", + "type": "feature", + "status": "new", + "created_at": "2026-03-07T12:00:00Z", + "updated_at": "2026-03-07T12:00:00Z", + "description": "Add Audit Log Filtering by Client" +} diff --git a/conductor/archive/audit_log_filtering_by_client_20260307/plan.md b/conductor/archive/audit_log_filtering_by_client_20260307/plan.md new file mode 100644 index 0000000..7dec903 --- /dev/null +++ b/conductor/archive/audit_log_filtering_by_client_20260307/plan.md @@ -0,0 +1,39 @@ +# Implementation Plan: Add Audit Log Filtering by Client + +## Phase 1: Repository Layer Update [checkpoint: a640ed9] +- [x] Task: Update `AuditLogRepository` interface in `internal/auth/usecase/interface.go` to include `clientID *uuid.UUID` in `ListCursor`. 97bee6d +- [x] Task: Update `PostgreSQLAuditLogRepository` in `internal/auth/repository/postgresql/postgresql_audit_log_repository.go`. 8501e96 + - [x] Update `ListCursor` to support `client_id` filtering. + - [x] Update/Add tests in `internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go`. +- [x] Task: Update `MySQLAuditLogRepository` in `internal/auth/repository/mysql/mysql_audit_log_repository.go`. 8501e96 + - [x] Update `ListCursor` to support `client_id` filtering. + - [x] Update/Add tests in `internal/auth/repository/mysql/mysql_audit_log_repository_test.go`. +- [x] Task: Add database index for `client_id` in `audit_logs` table. c606ac9 + - [x] Create migration `000007_add_audit_log_client_id_index`. +- [x] Task: Conductor - User Manual Verification 'Phase 1' (Protocol in workflow.md) a640ed9 + +## Phase 2: Use Case Layer Update [checkpoint: b9f7b38] +- [x] Task: Update `AuditLogUseCase` interface in `internal/auth/usecase/interface.go` to include `clientID *uuid.UUID` in `ListCursor`. 97bee6d +- [x] Task: Update `auditLogUseCase` in `internal/auth/usecase/audit_log_usecase.go`. 991c9dd + - [x] Update `ListCursor` to pass `clientID` to the repository. + - [x] Update/Add tests in `internal/auth/usecase/audit_log_usecase_test.go`. +- [x] Task: Update `auditLogUseCaseWithMetrics` decorator in `internal/auth/usecase/metrics_decorator.go`. 991c9dd + - [x] Update `ListCursor` signature and implementation. + - [x] Update tests in `internal/auth/usecase/metrics_decorator_test.go`. +- [x] Task: Conductor - User Manual Verification 'Phase 2' (Protocol in workflow.md) b9f7b38 + +## Phase 3: HTTP Handler Layer Update [checkpoint: c2c5e8a] +- [x] Task: Update `AuditLogHandler.ListHandler` in `internal/auth/http/audit_log_handler.go`. 4ef8ee2 + - [x] Parse `client_id` query parameter. + - [x] Validate `client_id` is a valid UUID. + - [x] Pass `clientID` to the use case. + - [x] Update/Add tests in `internal/auth/http/audit_log_handler_test.go`. +- [x] Task: Conductor - User Manual Verification 'Phase 3' (Protocol in workflow.md) c2c5e8a + +## Phase 4: Documentation and Integration Testing [checkpoint: def8bbe] +- [x] Task: Update Documentation. bf60d39 + - [x] Document `client_id` filter in `docs/observability/audit-logs.md`. + - [x] Update `docs/openapi.yaml` with the new query parameter. +- [x] Task: Update Integration Tests. bf60d39 + - [x] Add audit log filtering test case in `test/integration/auth_flow_test.go`. +- [x] Task: Conductor - User Manual Verification 'Phase 4' (Protocol in workflow.md) def8bbe diff --git a/conductor/archive/audit_log_filtering_by_client_20260307/spec.md b/conductor/archive/audit_log_filtering_by_client_20260307/spec.md new file mode 100644 index 0000000..5122c5f --- /dev/null +++ b/conductor/archive/audit_log_filtering_by_client_20260307/spec.md @@ -0,0 +1,36 @@ +# Specification: Add Audit Log Filtering by Client + +## Overview +Currently, audit logs can be retrieved and filtered by date range. This track adds the ability to filter audit logs by a specific Client ID (UUID) via the API. + +## Functional Requirements +- **API Filtering:** The `GET /v1/audit-logs` endpoint must support an optional `client_id` query parameter. +- **Repository Support:** The `AuditLogRepository` must implement filtering by `client_id` in its `ListCursor` method for both PostgreSQL and MySQL implementations. +- **UseCase Support:** The `AuditLogUseCase` must pass the `client_id` filter from the handler to the repository. +- **Validation:** The `client_id` provided in the query parameter must be a valid UUID. +- **Empty Results:** If no audit logs match the specified `client_id`, the API should return an empty list with a `200 OK` status. +- **Documentation:** + - Update `docs/observability/audit-logs.md` to document the new `client_id` filter. + - Update `docs/openapi.yaml` to include the `client_id` query parameter for the audit logs list endpoint. +- **Integration Tests:** + - Update `test/integration/auth_flow_test.go` to include a test case for filtering audit logs by Client ID. + +## Non-Functional Requirements +- **Performance:** Ensure that the database query for filtering by `client_id` is performant. +- **Consistency:** Maintain existing cursor-based pagination and date filtering logic. + +## Acceptance Criteria +- [ ] `GET /v1/audit-logs?client_id=` returns only logs belonging to that client. +- [ ] Providing an invalid UUID for `client_id` returns a `400 Bad Request` error. +- [ ] If `client_id` is omitted, the API continues to return logs for all clients (existing behavior). +- [ ] Filtering by `client_id` works correctly in combination with `created_at_from` and `created_at_to` filters. +- [ ] Filtering by `client_id` works correctly with cursor-based pagination (`after_id`). +- [ ] `docs/observability/audit-logs.md` correctly reflects the new filtering capability. +- [ ] `docs/openapi.yaml` includes the new `client_id` query parameter. +- [ ] Integration tests in `test/integration/auth_flow_test.go` pass and verify the new filtering behavior. +- [ ] PostgreSQL implementation is verified with integration tests. +- [ ] MySQL implementation is verified with integration tests. + +## Out of Scope +- Filtering by Client Name. +- Adding filtering to the CLI `audit-log list` command. diff --git a/conductor/product.md b/conductor/product.md index 1d4836b..1acc6b4 100644 --- a/conductor/product.md +++ b/conductor/product.md @@ -17,7 +17,7 @@ To provide a secure, developer-friendly, and lightweight secrets management plat - **Tokenization Engine:** Format-preserving tokens for sensitive data types like credit card numbers. - **Auth Token Revocation:** Immediate invalidation of authentication tokens (single or client-wide) with full state management. - **Client Secret Rotation:** Self-service and administrative rotation of client secrets with automatic auth token revocation. -- **Audit Logs:** HMAC-signed audit trails capturing every access attempt and policy evaluation. +- **Audit Logs:** HMAC-signed audit trails capturing every access attempt and policy evaluation, with support for advanced filtering by client and date range. - **KMS Integration:** Native support for AWS KMS, Google Cloud KMS, Azure Key Vault, and HashiCorp Vault. ## Strategic Priorities diff --git a/conductor/tracks.md b/conductor/tracks.md index 0b5c54e..22d3d64 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -1,5 +1,3 @@ # Project Tracks This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. - ---- diff --git a/docs/observability/audit-logs.md b/docs/observability/audit-logs.md index e89551e..b55448b 100644 --- a/docs/observability/audit-logs.md +++ b/docs/observability/audit-logs.md @@ -30,6 +30,7 @@ graph TD - `limit` (default 50, max 100) - `created_at_from` (RFC3339) - `created_at_to` (RFC3339) + - `client_id` (UUID filter) ```bash curl "http://localhost:8080/v1/audit-logs?created_at_from=2026-02-27T00:00:00Z&limit=20" diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 48f8454..0fb7d79 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1031,6 +1031,12 @@ paths: schema: type: string format: date-time + - name: client_id + in: query + description: Filter by specific client ID (UUID format) + schema: + type: string + format: uuid responses: "200": description: Audit logs list diff --git a/internal/auth/http/audit_log_handler.go b/internal/auth/http/audit_log_handler.go index c016902..02717bb 100644 --- a/internal/auth/http/audit_log_handler.go +++ b/internal/auth/http/audit_log_handler.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/allisson/secrets/internal/auth/http/dto" authUseCase "github.com/allisson/secrets/internal/auth/usecase" @@ -32,11 +33,12 @@ func NewAuditLogHandler( } // ListHandler retrieves audit logs with cursor pagination and optional time-based filtering. -// GET /v1/audit-logs?after_id=&limit=50&created_at_from=2026-02-01T00:00:00Z&created_at_to=2026-02-14T23:59:59Z +// GET /v1/audit-logs?after_id=&limit=50&created_at_from=2026-02-01T00:00:00Z&created_at_to=2026-02-14T23:59:59Z&client_id= // Requires ReadCapability on path /v1/audit-logs. Returns 200 OK with paginated audit log list // ordered by created_at descending (newest first). Accepts optional created_at_from and // created_at_to query parameters in RFC3339 format. Timestamps are converted to UTC. Both // boundaries are inclusive (>= and <=). Uses cursor-based pagination with after_id parameter. +// Accepts optional client_id query parameter (UUID format). func (h *AuditLogHandler) ListHandler(c *gin.Context) { // Parse cursor and limit query parameters afterID, limit, err := httputil.ParseUUIDCursorPagination(c, "after_id") @@ -45,6 +47,23 @@ func (h *AuditLogHandler) ListHandler(c *gin.Context) { return } + // Parse optional client_id query parameter + var clientID *uuid.UUID + if clientIDStr := c.Query("client_id"); clientIDStr != "" { + parsed, err := uuid.Parse(clientIDStr) + if err != nil { + httputil.HandleBadRequestGin( + c, + fmt.Errorf( + "invalid client_id format: must be a valid UUID (e.g., 550e8400-e29b-41d4-a716-446655440000)", + ), + h.logger, + ) + return + } + clientID = &parsed + } + // Parse optional created_at_from query parameter var createdAtFrom *time.Time if fromStr := c.Query("created_at_from"); fromStr != "" { @@ -88,6 +107,7 @@ func (h *AuditLogHandler) ListHandler(c *gin.Context) { limit+1, createdAtFrom, createdAtTo, + clientID, ) if err != nil { httputil.HandleErrorGin(c, err, h.logger) diff --git a/internal/auth/http/audit_log_handler_test.go b/internal/auth/http/audit_log_handler_test.go index 9b77c3b..73cf39b 100644 --- a/internal/auth/http/audit_log_handler_test.go +++ b/internal/auth/http/audit_log_handler_test.go @@ -68,7 +68,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { } mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -97,7 +97,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { expectedAuditLogs := []*authDomain.AuditLog{} mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 101, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 101, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -119,7 +119,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { expectedAuditLogs := []*authDomain.AuditLog{} mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -140,7 +140,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { // Mock expectation - handler will proceed with defaults since offset is not a valid parameter mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return([]*authDomain.AuditLog{}, nil). Once() @@ -156,7 +156,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { // Mock expectation - handler will proceed with defaults since offset is not a valid parameter mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return([]*authDomain.AuditLog{}, nil). Once() @@ -187,7 +187,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { // Mock usecase to expect clamped limit of 1000 mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 1001, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 1001, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return([]*authDomain.AuditLog{}, nil). Once() @@ -217,7 +217,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { handler, mockUseCase := setupTestAuditLogHandler(t) mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). Return(nil, errors.New("database error")). Once() @@ -256,7 +256,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { } mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -300,7 +300,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { } mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), &createdAtTo). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), &createdAtTo, (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -345,7 +345,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { } mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, &createdAtTo). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, &createdAtTo, (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -381,7 +381,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { expectedAuditLogs := []*authDomain.AuditLog{} mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFromUTC, (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFromUTC, (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -469,7 +469,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { expectedAuditLogs := []*authDomain.AuditLog{} mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &now, &now). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &now, &now, (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -499,7 +499,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { expectedAuditLogs := []*authDomain.AuditLog{} mockUseCase.EXPECT(). - ListCursor(mock.Anything, (*uuid.UUID)(nil), 26, &createdAtFrom, (*time.Time)(nil)). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 26, &createdAtFrom, (*time.Time)(nil), (*uuid.UUID)(nil)). Return(expectedAuditLogs, nil). Once() @@ -518,4 +518,64 @@ func TestAuditLogHandler_ListHandler(t *testing.T) { assert.NoError(t, err) assert.Len(t, response.Data, 0) }) + + t.Run("Success_WithClientIDFilter", func(t *testing.T) { + handler, mockUseCase := setupTestAuditLogHandler(t) + + clientID := uuid.New() + id := uuid.Must(uuid.NewV7()) + + expectedAuditLogs := []*authDomain.AuditLog{ + { + ID: id, + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID, + Capability: authDomain.ReadCapability, + Path: "/v1/secrets/test", + CreatedAt: time.Now().UTC(), + }, + } + + mockUseCase.EXPECT(). + ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), &clientID). + Return(expectedAuditLogs, nil). + Once() + + c, w := createTestContext( + http.MethodGet, + "/v1/audit-logs?client_id="+clientID.String(), + nil, + ) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response dto.ListAuditLogsResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Len(t, response.Data, 1) + assert.Equal(t, id.String(), response.Data[0].ID) + assert.Equal(t, clientID.String(), response.Data[0].ClientID) + }) + + t.Run("Error_InvalidClientIDFormat", func(t *testing.T) { + handler, _ := setupTestAuditLogHandler(t) + + c, w := createTestContext( + http.MethodGet, + "/v1/audit-logs?client_id=invalid-uuid", + nil, + ) + + handler.ListHandler(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "bad_request", response["error"]) + assert.Contains(t, response["message"], "invalid client_id format") + }) } diff --git a/internal/auth/repository/mysql/mysql_audit_log_repository.go b/internal/auth/repository/mysql/mysql_audit_log_repository.go index 2f75f65..6646eda 100644 --- a/internal/auth/repository/mysql/mysql_audit_log_repository.go +++ b/internal/auth/repository/mysql/mysql_audit_log_repository.go @@ -162,6 +162,7 @@ func (m *MySQLAuditLogRepository) Get(ctx context.Context, id uuid.UUID) (*authD // 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. Handles NULL metadata gracefully by // returning nil map for those entries. UUIDs are stored as BINARY(16) and must be unmarshaled. Limit is pre-validated (1-1000). func (m *MySQLAuditLogRepository) ListCursor( @@ -169,6 +170,7 @@ func (m *MySQLAuditLogRepository) ListCursor( afterID *uuid.UUID, limit int, createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, ) ([]*authDomain.AuditLog, error) { querier := database.GetTx(ctx, m.db) @@ -196,6 +198,15 @@ func (m *MySQLAuditLogRepository) ListCursor( args = append(args, *createdAtTo) } + if clientID != nil { + clientIDBinary, err := clientID.MarshalBinary() + if err != nil { + return nil, apperrors.Wrap(err, "failed to marshal clientID to binary") + } + conditions = append(conditions, "client_id = ?") + args = append(args, clientIDBinary) + } + // Build query query := `SELECT id, request_id, client_id, capability, path, metadata, signature, kek_id, is_signed, created_at FROM audit_logs` diff --git a/internal/auth/repository/mysql/mysql_audit_log_repository_test.go b/internal/auth/repository/mysql/mysql_audit_log_repository_test.go index c4aa31b..d6e8bb6 100644 --- a/internal/auth/repository/mysql/mysql_audit_log_repository_test.go +++ b/internal/auth/repository/mysql/mysql_audit_log_repository_test.go @@ -381,7 +381,7 @@ func TestMySQLAuditLogRepository_ListCursor_FirstPage(t *testing.T) { } // First page with no cursor (limit 3) - logs, err := repo.ListCursor(ctx, nil, 3, nil, nil) + logs, err := repo.ListCursor(ctx, nil, 3, nil, nil, nil) require.NoError(t, err) assert.Len(t, logs, 3) @@ -419,13 +419,13 @@ func TestMySQLAuditLogRepository_ListCursor_SubsequentPages(t *testing.T) { } // First page (no cursor, limit 3) - page1, err := repo.ListCursor(ctx, nil, 3, nil, nil) + page1, err := repo.ListCursor(ctx, nil, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page1, 3) // Second page (use last ID from page1 as cursor) lastIDPage1 := page1[len(page1)-1].ID - page2, err := repo.ListCursor(ctx, &lastIDPage1, 3, nil, nil) + page2, err := repo.ListCursor(ctx, &lastIDPage1, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page2, 3) @@ -438,7 +438,7 @@ func TestMySQLAuditLogRepository_ListCursor_SubsequentPages(t *testing.T) { // Third page (use last ID from page2 as cursor) lastIDPage2 := page2[len(page2)-1].ID - page3, err := repo.ListCursor(ctx, &lastIDPage2, 3, nil, nil) + page3, err := repo.ListCursor(ctx, &lastIDPage2, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page3, 3) @@ -455,7 +455,7 @@ func TestMySQLAuditLogRepository_ListCursor_EmptyResult(t *testing.T) { ctx := context.Background() // List with no data - logs, err := repo.ListCursor(ctx, nil, 10, nil, nil) + logs, err := repo.ListCursor(ctx, nil, 10, nil, nil, nil) require.NoError(t, err) assert.NotNil(t, logs) assert.Len(t, logs, 0) @@ -490,7 +490,7 @@ func TestMySQLAuditLogRepository_ListCursor_WithFilters(t *testing.T) { // Filter: created_at >= (now - 2 hours) filterFrom := now.Add(-2 * time.Hour) - logs, err := repo.ListCursor(ctx, nil, 10, &filterFrom, nil) + logs, err := repo.ListCursor(ctx, nil, 10, &filterFrom, nil, nil) require.NoError(t, err) assert.LessOrEqual(t, len(logs), 3) // Should get logs from 0, -1, -2 hours @@ -499,3 +499,66 @@ func TestMySQLAuditLogRepository_ListCursor_WithFilters(t *testing.T) { assert.True(t, log.CreatedAt.After(filterFrom) || log.CreatedAt.Equal(filterFrom)) } } + +func TestMySQLAuditLogRepository_ListCursor_ByClientID(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLAuditLogRepository(db) + ctx := context.Background() + + // Create test clients + clientID1 := testutil.CreateTestClient(t, db, "mysql", "client-1") + clientID2 := testutil.CreateTestClient(t, db, "mysql", "client-2") + + // Create logs for client 1 + for i := 0; i < 3; i++ { + auditLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID1, + Capability: authDomain.ReadCapability, + Path: "/secrets/client1", + CreatedAt: time.Now().UTC(), + } + require.NoError(t, repo.Create(ctx, auditLog)) + time.Sleep(1 * time.Millisecond) + } + + // Create logs for client 2 + for i := 0; i < 2; i++ { + auditLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID2, + Capability: authDomain.ReadCapability, + Path: "/secrets/client2", + CreatedAt: time.Now().UTC(), + } + require.NoError(t, repo.Create(ctx, auditLog)) + time.Sleep(1 * time.Millisecond) + } + + // Filter by client 1 + logs, err := repo.ListCursor(ctx, nil, 10, nil, nil, &clientID1) + require.NoError(t, err) + assert.Len(t, logs, 3) + for _, log := range logs { + assert.Equal(t, clientID1, log.ClientID) + } + + // Filter by client 2 + logs, err = repo.ListCursor(ctx, nil, 10, nil, nil, &clientID2) + require.NoError(t, err) + assert.Len(t, logs, 2) + for _, log := range logs { + assert.Equal(t, clientID2, log.ClientID) + } + + // Filter by non-existent client ID + nonExistentClientID := uuid.New() + logs, err = repo.ListCursor(ctx, nil, 10, nil, nil, &nonExistentClientID) + require.NoError(t, err) + assert.Len(t, logs, 0) +} diff --git a/internal/auth/repository/postgresql/postgresql_audit_log_repository.go b/internal/auth/repository/postgresql/postgresql_audit_log_repository.go index bf0bed6..516bae1 100644 --- a/internal/auth/repository/postgresql/postgresql_audit_log_repository.go +++ b/internal/auth/repository/postgresql/postgresql_audit_log_repository.go @@ -110,6 +110,7 @@ func (p *PostgreSQLAuditLogRepository) Get(ctx context.Context, id uuid.UUID) (* // 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. Handles NULL metadata gracefully by // returning nil map for those entries. Limit is pre-validated (1-1000). func (p *PostgreSQLAuditLogRepository) ListCursor( @@ -117,6 +118,7 @@ func (p *PostgreSQLAuditLogRepository) ListCursor( afterID *uuid.UUID, limit int, createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, ) ([]*authDomain.AuditLog, error) { querier := database.GetTx(ctx, p.db) @@ -144,6 +146,12 @@ func (p *PostgreSQLAuditLogRepository) ListCursor( paramIndex++ } + if clientID != nil { + conditions = append(conditions, fmt.Sprintf("client_id = $%d", paramIndex)) + args = append(args, *clientID) + paramIndex++ + } + // Build query query := `SELECT id, request_id, client_id, capability, path, metadata, signature, kek_id, is_signed, created_at FROM audit_logs` diff --git a/internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go b/internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go index e96b250..984aa66 100644 --- a/internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go +++ b/internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go @@ -353,7 +353,7 @@ func TestPostgreSQLAuditLogRepository_ListCursor_FirstPage(t *testing.T) { } // First page with no cursor (limit 3) - logs, err := repo.ListCursor(ctx, nil, 3, nil, nil) + logs, err := repo.ListCursor(ctx, nil, 3, nil, nil, nil) require.NoError(t, err) assert.Len(t, logs, 3) @@ -391,13 +391,13 @@ func TestPostgreSQLAuditLogRepository_ListCursor_SubsequentPages(t *testing.T) { } // First page (no cursor, limit 3) - page1, err := repo.ListCursor(ctx, nil, 3, nil, nil) + page1, err := repo.ListCursor(ctx, nil, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page1, 3) // Second page (use last ID from page1 as cursor) lastIDPage1 := page1[len(page1)-1].ID - page2, err := repo.ListCursor(ctx, &lastIDPage1, 3, nil, nil) + page2, err := repo.ListCursor(ctx, &lastIDPage1, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page2, 3) @@ -410,7 +410,7 @@ func TestPostgreSQLAuditLogRepository_ListCursor_SubsequentPages(t *testing.T) { // Third page (use last ID from page2 as cursor) lastIDPage2 := page2[len(page2)-1].ID - page3, err := repo.ListCursor(ctx, &lastIDPage2, 3, nil, nil) + page3, err := repo.ListCursor(ctx, &lastIDPage2, 3, nil, nil, nil) require.NoError(t, err) require.Len(t, page3, 3) @@ -427,7 +427,7 @@ func TestPostgreSQLAuditLogRepository_ListCursor_EmptyResult(t *testing.T) { ctx := context.Background() // List with no data - logs, err := repo.ListCursor(ctx, nil, 10, nil, nil) + logs, err := repo.ListCursor(ctx, nil, 10, nil, nil, nil) require.NoError(t, err) assert.NotNil(t, logs) assert.Len(t, logs, 0) @@ -462,7 +462,7 @@ func TestPostgreSQLAuditLogRepository_ListCursor_WithFilters(t *testing.T) { // Filter: created_at >= (now - 2 hours) filterFrom := now.Add(-2 * time.Hour) - logs, err := repo.ListCursor(ctx, nil, 10, &filterFrom, nil) + logs, err := repo.ListCursor(ctx, nil, 10, &filterFrom, nil, nil) require.NoError(t, err) assert.LessOrEqual(t, len(logs), 3) // Should get logs from 0, -1, -2 hours @@ -471,3 +471,66 @@ func TestPostgreSQLAuditLogRepository_ListCursor_WithFilters(t *testing.T) { assert.True(t, log.CreatedAt.After(filterFrom) || log.CreatedAt.Equal(filterFrom)) } } + +func TestPostgreSQLAuditLogRepository_ListCursor_ByClientID(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLAuditLogRepository(db) + ctx := context.Background() + + // Create test clients + clientID1 := testutil.CreateTestClient(t, db, "postgres", "client-1") + clientID2 := testutil.CreateTestClient(t, db, "postgres", "client-2") + + // Create logs for client 1 + for i := 0; i < 3; i++ { + auditLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID1, + Capability: authDomain.ReadCapability, + Path: "/secrets/client1", + CreatedAt: time.Now().UTC(), + } + require.NoError(t, repo.Create(ctx, auditLog)) + time.Sleep(1 * time.Millisecond) + } + + // Create logs for client 2 + for i := 0; i < 2; i++ { + auditLog := &authDomain.AuditLog{ + ID: uuid.Must(uuid.NewV7()), + RequestID: uuid.Must(uuid.NewV7()), + ClientID: clientID2, + Capability: authDomain.ReadCapability, + Path: "/secrets/client2", + CreatedAt: time.Now().UTC(), + } + require.NoError(t, repo.Create(ctx, auditLog)) + time.Sleep(1 * time.Millisecond) + } + + // Filter by client 1 + logs, err := repo.ListCursor(ctx, nil, 10, nil, nil, &clientID1) + require.NoError(t, err) + assert.Len(t, logs, 3) + for _, log := range logs { + assert.Equal(t, clientID1, log.ClientID) + } + + // Filter by client 2 + logs, err = repo.ListCursor(ctx, nil, 10, nil, nil, &clientID2) + require.NoError(t, err) + assert.Len(t, logs, 2) + for _, log := range logs { + assert.Equal(t, clientID2, log.ClientID) + } + + // Filter by non-existent client ID + nonExistentClientID := uuid.New() + logs, err = repo.ListCursor(ctx, nil, 10, nil, nil, &nonExistentClientID) + require.NoError(t, err) + assert.Len(t, logs, 0) +} diff --git a/internal/auth/usecase/audit_log_usecase.go b/internal/auth/usecase/audit_log_usecase.go index e3be645..8abb41d 100644 --- a/internal/auth/usecase/audit_log_usecase.go +++ b/internal/auth/usecase/audit_log_usecase.go @@ -82,14 +82,16 @@ func (a *auditLogUseCase) Create( // 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). func (a *auditLogUseCase) ListCursor( ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, ) ([]*authDomain.AuditLog, error) { - auditLogs, err := a.auditLogRepo.ListCursor(ctx, afterID, limit, createdAtFrom, createdAtTo) + auditLogs, err := a.auditLogRepo.ListCursor(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) if err != nil { return nil, apperrors.Wrap(err, "failed to list audit logs with cursor") } @@ -160,7 +162,7 @@ func (a *auditLogUseCase) VerifyBatch( for { // Retrieve logs in time range - logs, err := a.auditLogRepo.ListCursor(ctx, afterID, pageSize, &startTime, &endTime) + logs, err := a.auditLogRepo.ListCursor(ctx, afterID, pageSize, &startTime, &endTime, nil) if err != nil { return nil, apperrors.Wrap(err, "failed to list audit logs") } diff --git a/internal/auth/usecase/audit_log_usecase_test.go b/internal/auth/usecase/audit_log_usecase_test.go index c4f02c1..491b6ee 100644 --- a/internal/auth/usecase/audit_log_usecase_test.go +++ b/internal/auth/usecase/audit_log_usecase_test.go @@ -48,8 +48,9 @@ func (m *mockAuditLogRepository) ListCursor( afterID *uuid.UUID, limit int, createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, ) ([]*authDomain.AuditLog, error) { - args := m.Called(ctx, afterID, limit, createdAtFrom, createdAtTo) + args := m.Called(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -452,3 +453,64 @@ func TestAuditLogUseCase_DeleteOlderThan(t *testing.T) { mockRepo.AssertExpectations(t) }) } + +func TestAuditLogUseCase_ListCursor(t *testing.T) { + ctx := context.Background() + + t.Run("Success_ListWithAllFilters", func(t *testing.T) { + mockRepo := &mockAuditLogRepository{} + + afterID := uuid.Must(uuid.NewV7()) + limit := 50 + from := time.Now().Add(-1 * time.Hour) + to := time.Now() + clientID := uuid.Must(uuid.NewV7()) + + expectedLogs := []*authDomain.AuditLog{{ID: uuid.New()}} + + mockRepo.On("ListCursor", ctx, &afterID, limit, &from, &to, &clientID). + Return(expectedLogs, nil). + Once() + + useCase := NewAuditLogUseCase(mockRepo, nil, nil) + + logs, err := useCase.ListCursor(ctx, &afterID, limit, &from, &to, &clientID) + + assert.NoError(t, err) + assert.Equal(t, expectedLogs, logs) + mockRepo.AssertExpectations(t) + }) + + t.Run("Success_ListWithNilFilters", func(t *testing.T) { + mockRepo := &mockAuditLogRepository{} + + mockRepo.On("ListCursor", ctx, (*uuid.UUID)(nil), 10, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)). + Return([]*authDomain.AuditLog{}, nil). + Once() + + useCase := NewAuditLogUseCase(mockRepo, nil, nil) + + logs, err := useCase.ListCursor(ctx, nil, 10, nil, nil, nil) + + assert.NoError(t, err) + assert.Empty(t, logs) + mockRepo.AssertExpectations(t) + }) + + t.Run("Error_RepositoryFailure", func(t *testing.T) { + mockRepo := &mockAuditLogRepository{} + + mockRepo.On("ListCursor", ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("db error")). + Once() + + useCase := NewAuditLogUseCase(mockRepo, nil, nil) + + logs, err := useCase.ListCursor(ctx, nil, 10, nil, nil, nil) + + assert.Error(t, err) + assert.Nil(t, logs) + assert.Contains(t, err.Error(), "failed to list audit logs with cursor") + mockRepo.AssertExpectations(t) + }) +} diff --git a/internal/auth/usecase/interface.go b/internal/auth/usecase/interface.go index e5ba432..22843c9 100644 --- a/internal/auth/usecase/interface.go +++ b/internal/auth/usecase/interface.go @@ -73,12 +73,14 @@ type AuditLogRepository interface { // 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. @@ -209,12 +211,14 @@ type AuditLogUseCase interface { // 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 older than the specified number of days. diff --git a/internal/auth/usecase/metrics_decorator.go b/internal/auth/usecase/metrics_decorator.go index cc439be..d11d27c 100644 --- a/internal/auth/usecase/metrics_decorator.go +++ b/internal/auth/usecase/metrics_decorator.go @@ -293,9 +293,10 @@ func (a *auditLogUseCaseWithMetrics) ListCursor( afterID *uuid.UUID, limit int, createdAtFrom, createdAtTo *time.Time, + clientID *uuid.UUID, ) ([]*authDomain.AuditLog, error) { start := time.Now() - logs, err := a.next.ListCursor(ctx, afterID, limit, createdAtFrom, createdAtTo) + logs, err := a.next.ListCursor(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) status := "success" if err != nil { diff --git a/internal/auth/usecase/metrics_decorator_test.go b/internal/auth/usecase/metrics_decorator_test.go index 8de4239..cc66adf 100644 --- a/internal/auth/usecase/metrics_decorator_test.go +++ b/internal/auth/usecase/metrics_decorator_test.go @@ -124,4 +124,26 @@ func TestAuditLogUseCaseWithMetrics(t *testing.T) { mockNext.AssertExpectations(t) mockMetrics.AssertExpectations(t) }) + + t.Run("List success", func(t *testing.T) { + afterID := uuid.New() + clientID := uuid.New() + from := time.Now().Add(-1 * time.Hour) + to := time.Now() + expectedLogs := []*authDomain.AuditLog{{ID: uuid.New()}} + + mockNext.On("ListCursor", ctx, &afterID, 50, &from, &to, &clientID). + Return(expectedLogs, nil). + Once() + mockMetrics.On("RecordOperation", ctx, "auth", "audit_log_list", "success").Return().Once() + mockMetrics.On("RecordDuration", ctx, "auth", "audit_log_list", mock.AnythingOfType("time.Duration"), "success"). + Return(). + Once() + + res, err := uc.ListCursor(ctx, &afterID, 50, &from, &to, &clientID) + assert.NoError(t, err) + assert.Equal(t, expectedLogs, res) + mockNext.AssertExpectations(t) + mockMetrics.AssertExpectations(t) + }) } diff --git a/internal/auth/usecase/mocks/mocks.go b/internal/auth/usecase/mocks/mocks.go index 80c7ce9..cfea200 100644 --- a/internal/auth/usecase/mocks/mocks.go +++ b/internal/auth/usecase/mocks/mocks.go @@ -1048,8 +1048,8 @@ func (_c *MockAuditLogRepository_Get_Call) RunAndReturn(run func(ctx context.Con } // ListCursor provides a mock function for the type MockAuditLogRepository -func (_mock *MockAuditLogRepository) ListCursor(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time) ([]*domain.AuditLog, error) { - ret := _mock.Called(ctx, afterID, limit, createdAtFrom, createdAtTo) +func (_mock *MockAuditLogRepository) ListCursor(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID) ([]*domain.AuditLog, error) { + ret := _mock.Called(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) if len(ret) == 0 { panic("no return value specified for ListCursor") @@ -1057,18 +1057,18 @@ func (_mock *MockAuditLogRepository) ListCursor(ctx context.Context, afterID *uu var r0 []*domain.AuditLog var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) ([]*domain.AuditLog, error)); ok { - return returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) ([]*domain.AuditLog, error)); ok { + return returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) []*domain.AuditLog); ok { - r0 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) []*domain.AuditLog); ok { + r0 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*domain.AuditLog) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) error); ok { - r1 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(1).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) error); ok { + r1 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } else { r1 = ret.Error(1) } @@ -1086,11 +1086,12 @@ type MockAuditLogRepository_ListCursor_Call struct { // - limit int // - createdAtFrom *time.Time // - createdAtTo *time.Time -func (_e *MockAuditLogRepository_Expecter) ListCursor(ctx interface{}, afterID interface{}, limit interface{}, createdAtFrom interface{}, createdAtTo interface{}) *MockAuditLogRepository_ListCursor_Call { - return &MockAuditLogRepository_ListCursor_Call{Call: _e.mock.On("ListCursor", ctx, afterID, limit, createdAtFrom, createdAtTo)} +// - clientID *uuid.UUID +func (_e *MockAuditLogRepository_Expecter) ListCursor(ctx interface{}, afterID interface{}, limit interface{}, createdAtFrom interface{}, createdAtTo interface{}, clientID interface{}) *MockAuditLogRepository_ListCursor_Call { + return &MockAuditLogRepository_ListCursor_Call{Call: _e.mock.On("ListCursor", ctx, afterID, limit, createdAtFrom, createdAtTo, clientID)} } -func (_c *MockAuditLogRepository_ListCursor_Call) Run(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time)) *MockAuditLogRepository_ListCursor_Call { +func (_c *MockAuditLogRepository_ListCursor_Call) Run(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID)) *MockAuditLogRepository_ListCursor_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -1112,12 +1113,17 @@ func (_c *MockAuditLogRepository_ListCursor_Call) Run(run func(ctx context.Conte if args[4] != nil { arg4 = args[4].(*time.Time) } + var arg5 *uuid.UUID + if args[5] != nil { + arg5 = args[5].(*uuid.UUID) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -1128,7 +1134,7 @@ func (_c *MockAuditLogRepository_ListCursor_Call) Return(auditLogs []*domain.Aud return _c } -func (_c *MockAuditLogRepository_ListCursor_Call) RunAndReturn(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time) ([]*domain.AuditLog, error)) *MockAuditLogRepository_ListCursor_Call { +func (_c *MockAuditLogRepository_ListCursor_Call) RunAndReturn(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID) ([]*domain.AuditLog, error)) *MockAuditLogRepository_ListCursor_Call { _c.Call.Return(run) return _c } @@ -2139,8 +2145,8 @@ func (_c *MockAuditLogUseCase_DeleteOlderThan_Call) RunAndReturn(run func(ctx co } // ListCursor provides a mock function for the type MockAuditLogUseCase -func (_mock *MockAuditLogUseCase) ListCursor(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time) ([]*domain.AuditLog, error) { - ret := _mock.Called(ctx, afterID, limit, createdAtFrom, createdAtTo) +func (_mock *MockAuditLogUseCase) ListCursor(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID) ([]*domain.AuditLog, error) { + ret := _mock.Called(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) if len(ret) == 0 { panic("no return value specified for ListCursor") @@ -2148,18 +2154,18 @@ func (_mock *MockAuditLogUseCase) ListCursor(ctx context.Context, afterID *uuid. var r0 []*domain.AuditLog var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) ([]*domain.AuditLog, error)); ok { - return returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) ([]*domain.AuditLog, error)); ok { + return returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } - if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) []*domain.AuditLog); ok { - r0 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(0).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) []*domain.AuditLog); ok { + r0 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*domain.AuditLog) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time) error); ok { - r1 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo) + if returnFunc, ok := ret.Get(1).(func(context.Context, *uuid.UUID, int, *time.Time, *time.Time, *uuid.UUID) error); ok { + r1 = returnFunc(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) } else { r1 = ret.Error(1) } @@ -2177,11 +2183,12 @@ type MockAuditLogUseCase_ListCursor_Call struct { // - limit int // - createdAtFrom *time.Time // - createdAtTo *time.Time -func (_e *MockAuditLogUseCase_Expecter) ListCursor(ctx interface{}, afterID interface{}, limit interface{}, createdAtFrom interface{}, createdAtTo interface{}) *MockAuditLogUseCase_ListCursor_Call { - return &MockAuditLogUseCase_ListCursor_Call{Call: _e.mock.On("ListCursor", ctx, afterID, limit, createdAtFrom, createdAtTo)} +// - clientID *uuid.UUID +func (_e *MockAuditLogUseCase_Expecter) ListCursor(ctx interface{}, afterID interface{}, limit interface{}, createdAtFrom interface{}, createdAtTo interface{}, clientID interface{}) *MockAuditLogUseCase_ListCursor_Call { + return &MockAuditLogUseCase_ListCursor_Call{Call: _e.mock.On("ListCursor", ctx, afterID, limit, createdAtFrom, createdAtTo, clientID)} } -func (_c *MockAuditLogUseCase_ListCursor_Call) Run(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time)) *MockAuditLogUseCase_ListCursor_Call { +func (_c *MockAuditLogUseCase_ListCursor_Call) Run(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID)) *MockAuditLogUseCase_ListCursor_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -2203,12 +2210,17 @@ func (_c *MockAuditLogUseCase_ListCursor_Call) Run(run func(ctx context.Context, if args[4] != nil { arg4 = args[4].(*time.Time) } + var arg5 *uuid.UUID + if args[5] != nil { + arg5 = args[5].(*uuid.UUID) + } run( arg0, arg1, arg2, arg3, arg4, + arg5, ) }) return _c @@ -2219,7 +2231,7 @@ func (_c *MockAuditLogUseCase_ListCursor_Call) Return(auditLogs []*domain.AuditL return _c } -func (_c *MockAuditLogUseCase_ListCursor_Call) RunAndReturn(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time) ([]*domain.AuditLog, error)) *MockAuditLogUseCase_ListCursor_Call { +func (_c *MockAuditLogUseCase_ListCursor_Call) RunAndReturn(run func(ctx context.Context, afterID *uuid.UUID, limit int, createdAtFrom *time.Time, createdAtTo *time.Time, clientID *uuid.UUID) ([]*domain.AuditLog, error)) *MockAuditLogUseCase_ListCursor_Call { _c.Call.Return(run) return _c } diff --git a/migrations/mysql/000007_add_audit_log_client_id_index.down.sql b/migrations/mysql/000007_add_audit_log_client_id_index.down.sql new file mode 100644 index 0000000..6557481 --- /dev/null +++ b/migrations/mysql/000007_add_audit_log_client_id_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_audit_logs_client_id_created_at ON audit_logs; diff --git a/migrations/mysql/000007_add_audit_log_client_id_index.up.sql b/migrations/mysql/000007_add_audit_log_client_id_index.up.sql new file mode 100644 index 0000000..e28a20c --- /dev/null +++ b/migrations/mysql/000007_add_audit_log_client_id_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_audit_logs_client_id_created_at ON audit_logs (client_id, created_at DESC); diff --git a/migrations/postgresql/000007_add_audit_log_client_id_index.down.sql b/migrations/postgresql/000007_add_audit_log_client_id_index.down.sql new file mode 100644 index 0000000..5200052 --- /dev/null +++ b/migrations/postgresql/000007_add_audit_log_client_id_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_audit_logs_client_id_created_at; diff --git a/migrations/postgresql/000007_add_audit_log_client_id_index.up.sql b/migrations/postgresql/000007_add_audit_log_client_id_index.up.sql new file mode 100644 index 0000000..e28a20c --- /dev/null +++ b/migrations/postgresql/000007_add_audit_log_client_id_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_audit_logs_client_id_created_at ON audit_logs (client_id, created_at DESC); diff --git a/test/integration/audit_log_signature_test.go b/test/integration/audit_log_signature_test.go index e2e2eb9..8aa62e3 100644 --- a/test/integration/audit_log_signature_test.go +++ b/test/integration/audit_log_signature_test.go @@ -81,7 +81,7 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { require.NoError(t, err, "failed to create audit log") // Retrieve the created log - logs, err := auditLogUseCase.ListCursor(ctx, nil, 1, nil, nil) + logs, err := auditLogUseCase.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") @@ -114,7 +114,7 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { require.NoError(t, err, "failed to create audit log") // Retrieve the log - logs, err := auditLogUseCase.ListCursor(ctx, nil, 1, nil, nil) + logs, err := auditLogUseCase.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") @@ -168,7 +168,7 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { require.NoError(t, err) // List audit logs for this client and path - logs, err := auditLogUseCase.ListCursor(ctx, nil, 10, nil, nil) + logs, err := auditLogUseCase.ListCursor(ctx, nil, 10, nil, nil, nil) require.NoError(t, err) var rotationLog *authDomain.AuditLog @@ -249,7 +249,7 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { // Get the created logs endTime := time.Now().UTC().Add(1 * time.Second) - logs, err := auditLogUseCase.ListCursor(ctx, nil, 3, &startTime, &endTime) + logs, err := auditLogUseCase.ListCursor(ctx, nil, 3, &startTime, &endTime, nil) require.NoError(t, err, "failed to list audit logs") require.Len(t, logs, 3, "expected 3 audit logs") @@ -305,7 +305,7 @@ func TestAuditLogSignature_EndToEnd(t *testing.T) { require.NoError(t, err, "failed to create legacy audit log") // Retrieve the log - logs, err := legacyUseCase.ListCursor(ctx, nil, 1, nil, nil) + 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") diff --git a/test/integration/auth_flow_test.go b/test/integration/auth_flow_test.go index ce217e2..e57d235 100644 --- a/test/integration/auth_flow_test.go +++ b/test/integration/auth_flow_test.go @@ -374,7 +374,81 @@ func TestIntegration_Auth_CompleteFlow(t *testing.T) { assert.NotEmpty(t, newTokenResponse.Token) }) - t.Logf("All 11 auth endpoint tests passed for %s", tc.dbDriver) + // [12/12] Test GET /v1/audit-logs - Filter by client ID + t.Run("12_AuditLogFilteringByClient", func(t *testing.T) { + // Create another client to have logs for a different client + clientRequest := authDTO.CreateClientRequest{ + Name: "Another Client", + IsActive: true, + Policies: []authDomain.PolicyDocument{{Path: "*", Capabilities: []authDomain.Capability{authDomain.ReadCapability}}}, + } + resp, body := ctx.makeRequest(t, http.MethodPost, "/v1/clients", clientRequest, true) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var clientResponse authDTO.CreateClientResponse + err := json.Unmarshal(body, &clientResponse) + require.NoError(t, err) + anotherClientID := clientResponse.ID + anotherClientSecret := clientResponse.Secret + + // Issue token for another client + issueRequest := authDTO.IssueTokenRequest{ + ClientID: anotherClientID, + ClientSecret: anotherClientSecret, + } + resp, body = ctx.makeRequest(t, http.MethodPost, "/v1/token", issueRequest, false) + require.Equal(t, http.StatusCreated, resp.StatusCode) + var tokenResponse authDTO.IssueTokenResponse + json.Unmarshal(body, &tokenResponse) + anotherToken := tokenResponse.Token + + // Perform an action with another client (GET self) + req, _ := http.NewRequest(http.MethodGet, ctx.server.URL+"/v1/clients/"+anotherClientID, nil) + req.Header.Set("Authorization", "Bearer "+anotherToken) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // List audit logs filtered by the another client (using root token) + resp, body = ctx.makeRequest( + t, + http.MethodGet, + "/v1/audit-logs?client_id="+anotherClientID, + nil, + true, + ) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response authDTO.ListAuditLogsResponse + err = json.Unmarshal(body, &response) + require.NoError(t, err) + assert.NotEmpty(t, response.Data) + + // All returned logs should belong to anotherClientID + for _, log := range response.Data { + assert.Equal(t, anotherClientID, log.ClientID) + } + + // List audit logs filtered by root client + resp, body = ctx.makeRequest( + t, + http.MethodGet, + "/v1/audit-logs?client_id="+ctx.rootClient.ID.String(), + nil, + true, + ) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + err = json.Unmarshal(body, &response) + require.NoError(t, err) + assert.NotEmpty(t, response.Data) + + for _, log := range response.Data { + assert.Equal(t, ctx.rootClient.ID.String(), log.ClientID) + } + }) + + t.Logf("All 12 auth endpoint tests passed for %s", tc.dbDriver) }) } }