Skip to content

Commit 6f6eb2b

Browse files
authored
feat(auth): implement audit log filtering by client_id (#118)
* chore(conductor): Add new track 'Add Audit Log Filtering by Client' * feat(auth): add clientID filter to AuditLogRepository and AuditLogUseCase * feat(auth): implement clientID filter in PostgreSQL and MySQL AuditLog repositories * feat(database): add index for client_id on audit_logs table * conductor(checkpoint): Checkpoint end of Phase 1 * conductor(plan): Mark Phase 1 as complete * feat(auth): implement clientID filter in AuditLogUseCase and metrics decorator * conductor(checkpoint): Checkpoint end of Phase 2 * conductor(plan): Mark Phase 2 as complete * feat(auth): implement client_id filter in AuditLogHandler * conductor(checkpoint): Checkpoint end of Phase 3 * conductor(plan): Mark Phase 3 as complete * docs(audit): document client_id filter and add integration tests * conductor(checkpoint): Checkpoint end of Phase 4 * conductor(plan): Mark Phase 4 as complete * chore(conductor): Mark track 'Add Audit Log Filtering by Client' as complete * docs(conductor): Synchronize docs for track 'Add Audit Log Filtering by Client' * chore(conductor): Archive track 'Add Audit Log Filtering by Client' * feat(auth): implement audit log filtering by client_id Added the ability to filter audit logs by a specific client ID via the API, including database optimizations and full-stack support. Key changes: - API: Added optional client_id query parameter to the audit logs list endpoint. - Logic: Updated Repository and Use Case layers to support clientID filtering. - Database: Created migrations to add an index on client_id in the audit_logs table for PostgreSQL and MySQL. - Observability: Updated the metrics decorator to include the new filter parameter. - Testing: Added unit tests for all layers and a new integration test case in auth_flow_test.go. - Documentation: Updated OpenAPI specifications and audit log reference guides.
1 parent bea48a1 commit 6f6eb2b

26 files changed

Lines changed: 562 additions & 63 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Track audit_log_filtering_by_client_20260307 Context
2+
3+
- [Specification](./spec.md)
4+
- [Implementation Plan](./plan.md)
5+
- [Metadata](./metadata.json)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"track_id": "audit_log_filtering_by_client_20260307",
3+
"type": "feature",
4+
"status": "new",
5+
"created_at": "2026-03-07T12:00:00Z",
6+
"updated_at": "2026-03-07T12:00:00Z",
7+
"description": "Add Audit Log Filtering by Client"
8+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Implementation Plan: Add Audit Log Filtering by Client
2+
3+
## Phase 1: Repository Layer Update [checkpoint: a640ed9]
4+
- [x] Task: Update `AuditLogRepository` interface in `internal/auth/usecase/interface.go` to include `clientID *uuid.UUID` in `ListCursor`. 97bee6d
5+
- [x] Task: Update `PostgreSQLAuditLogRepository` in `internal/auth/repository/postgresql/postgresql_audit_log_repository.go`. 8501e96
6+
- [x] Update `ListCursor` to support `client_id` filtering.
7+
- [x] Update/Add tests in `internal/auth/repository/postgresql/postgresql_audit_log_repository_test.go`.
8+
- [x] Task: Update `MySQLAuditLogRepository` in `internal/auth/repository/mysql/mysql_audit_log_repository.go`. 8501e96
9+
- [x] Update `ListCursor` to support `client_id` filtering.
10+
- [x] Update/Add tests in `internal/auth/repository/mysql/mysql_audit_log_repository_test.go`.
11+
- [x] Task: Add database index for `client_id` in `audit_logs` table. c606ac9
12+
- [x] Create migration `000007_add_audit_log_client_id_index`.
13+
- [x] Task: Conductor - User Manual Verification 'Phase 1' (Protocol in workflow.md) a640ed9
14+
15+
## Phase 2: Use Case Layer Update [checkpoint: b9f7b38]
16+
- [x] Task: Update `AuditLogUseCase` interface in `internal/auth/usecase/interface.go` to include `clientID *uuid.UUID` in `ListCursor`. 97bee6d
17+
- [x] Task: Update `auditLogUseCase` in `internal/auth/usecase/audit_log_usecase.go`. 991c9dd
18+
- [x] Update `ListCursor` to pass `clientID` to the repository.
19+
- [x] Update/Add tests in `internal/auth/usecase/audit_log_usecase_test.go`.
20+
- [x] Task: Update `auditLogUseCaseWithMetrics` decorator in `internal/auth/usecase/metrics_decorator.go`. 991c9dd
21+
- [x] Update `ListCursor` signature and implementation.
22+
- [x] Update tests in `internal/auth/usecase/metrics_decorator_test.go`.
23+
- [x] Task: Conductor - User Manual Verification 'Phase 2' (Protocol in workflow.md) b9f7b38
24+
25+
## Phase 3: HTTP Handler Layer Update [checkpoint: c2c5e8a]
26+
- [x] Task: Update `AuditLogHandler.ListHandler` in `internal/auth/http/audit_log_handler.go`. 4ef8ee2
27+
- [x] Parse `client_id` query parameter.
28+
- [x] Validate `client_id` is a valid UUID.
29+
- [x] Pass `clientID` to the use case.
30+
- [x] Update/Add tests in `internal/auth/http/audit_log_handler_test.go`.
31+
- [x] Task: Conductor - User Manual Verification 'Phase 3' (Protocol in workflow.md) c2c5e8a
32+
33+
## Phase 4: Documentation and Integration Testing [checkpoint: def8bbe]
34+
- [x] Task: Update Documentation. bf60d39
35+
- [x] Document `client_id` filter in `docs/observability/audit-logs.md`.
36+
- [x] Update `docs/openapi.yaml` with the new query parameter.
37+
- [x] Task: Update Integration Tests. bf60d39
38+
- [x] Add audit log filtering test case in `test/integration/auth_flow_test.go`.
39+
- [x] Task: Conductor - User Manual Verification 'Phase 4' (Protocol in workflow.md) def8bbe
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Specification: Add Audit Log Filtering by Client
2+
3+
## Overview
4+
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.
5+
6+
## Functional Requirements
7+
- **API Filtering:** The `GET /v1/audit-logs` endpoint must support an optional `client_id` query parameter.
8+
- **Repository Support:** The `AuditLogRepository` must implement filtering by `client_id` in its `ListCursor` method for both PostgreSQL and MySQL implementations.
9+
- **UseCase Support:** The `AuditLogUseCase` must pass the `client_id` filter from the handler to the repository.
10+
- **Validation:** The `client_id` provided in the query parameter must be a valid UUID.
11+
- **Empty Results:** If no audit logs match the specified `client_id`, the API should return an empty list with a `200 OK` status.
12+
- **Documentation:**
13+
- Update `docs/observability/audit-logs.md` to document the new `client_id` filter.
14+
- Update `docs/openapi.yaml` to include the `client_id` query parameter for the audit logs list endpoint.
15+
- **Integration Tests:**
16+
- Update `test/integration/auth_flow_test.go` to include a test case for filtering audit logs by Client ID.
17+
18+
## Non-Functional Requirements
19+
- **Performance:** Ensure that the database query for filtering by `client_id` is performant.
20+
- **Consistency:** Maintain existing cursor-based pagination and date filtering logic.
21+
22+
## Acceptance Criteria
23+
- [ ] `GET /v1/audit-logs?client_id=<uuid>` returns only logs belonging to that client.
24+
- [ ] Providing an invalid UUID for `client_id` returns a `400 Bad Request` error.
25+
- [ ] If `client_id` is omitted, the API continues to return logs for all clients (existing behavior).
26+
- [ ] Filtering by `client_id` works correctly in combination with `created_at_from` and `created_at_to` filters.
27+
- [ ] Filtering by `client_id` works correctly with cursor-based pagination (`after_id`).
28+
- [ ] `docs/observability/audit-logs.md` correctly reflects the new filtering capability.
29+
- [ ] `docs/openapi.yaml` includes the new `client_id` query parameter.
30+
- [ ] Integration tests in `test/integration/auth_flow_test.go` pass and verify the new filtering behavior.
31+
- [ ] PostgreSQL implementation is verified with integration tests.
32+
- [ ] MySQL implementation is verified with integration tests.
33+
34+
## Out of Scope
35+
- Filtering by Client Name.
36+
- Adding filtering to the CLI `audit-log list` command.

conductor/product.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ To provide a secure, developer-friendly, and lightweight secrets management plat
1717
- **Tokenization Engine:** Format-preserving tokens for sensitive data types like credit card numbers.
1818
- **Auth Token Revocation:** Immediate invalidation of authentication tokens (single or client-wide) with full state management.
1919
- **Client Secret Rotation:** Self-service and administrative rotation of client secrets with automatic auth token revocation.
20-
- **Audit Logs:** HMAC-signed audit trails capturing every access attempt and policy evaluation.
20+
- **Audit Logs:** HMAC-signed audit trails capturing every access attempt and policy evaluation, with support for advanced filtering by client and date range.
2121
- **KMS Integration:** Native support for AWS KMS, Google Cloud KMS, Azure Key Vault, and HashiCorp Vault.
2222

2323
## Strategic Priorities

conductor/tracks.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
# Project Tracks
22

33
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
4-
5-
---

docs/observability/audit-logs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ graph TD
3030
- `limit` (default 50, max 100)
3131
- `created_at_from` (RFC3339)
3232
- `created_at_to` (RFC3339)
33+
- `client_id` (UUID filter)
3334

3435
```bash
3536
curl "http://localhost:8080/v1/audit-logs?created_at_from=2026-02-27T00:00:00Z&limit=20"

docs/openapi.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,12 @@ paths:
10311031
schema:
10321032
type: string
10331033
format: date-time
1034+
- name: client_id
1035+
in: query
1036+
description: Filter by specific client ID (UUID format)
1037+
schema:
1038+
type: string
1039+
format: uuid
10341040
responses:
10351041
"200":
10361042
description: Audit logs list

internal/auth/http/audit_log_handler.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/gin-gonic/gin"
11+
"github.com/google/uuid"
1112

1213
"github.com/allisson/secrets/internal/auth/http/dto"
1314
authUseCase "github.com/allisson/secrets/internal/auth/usecase"
@@ -32,11 +33,12 @@ func NewAuditLogHandler(
3233
}
3334

3435
// ListHandler retrieves audit logs with cursor pagination and optional time-based filtering.
35-
// GET /v1/audit-logs?after_id=<uuid>&limit=50&created_at_from=2026-02-01T00:00:00Z&created_at_to=2026-02-14T23:59:59Z
36+
// GET /v1/audit-logs?after_id=<uuid>&limit=50&created_at_from=2026-02-01T00:00:00Z&created_at_to=2026-02-14T23:59:59Z&client_id=<uuid>
3637
// Requires ReadCapability on path /v1/audit-logs. Returns 200 OK with paginated audit log list
3738
// ordered by created_at descending (newest first). Accepts optional created_at_from and
3839
// created_at_to query parameters in RFC3339 format. Timestamps are converted to UTC. Both
3940
// boundaries are inclusive (>= and <=). Uses cursor-based pagination with after_id parameter.
41+
// Accepts optional client_id query parameter (UUID format).
4042
func (h *AuditLogHandler) ListHandler(c *gin.Context) {
4143
// Parse cursor and limit query parameters
4244
afterID, limit, err := httputil.ParseUUIDCursorPagination(c, "after_id")
@@ -45,6 +47,23 @@ func (h *AuditLogHandler) ListHandler(c *gin.Context) {
4547
return
4648
}
4749

50+
// Parse optional client_id query parameter
51+
var clientID *uuid.UUID
52+
if clientIDStr := c.Query("client_id"); clientIDStr != "" {
53+
parsed, err := uuid.Parse(clientIDStr)
54+
if err != nil {
55+
httputil.HandleBadRequestGin(
56+
c,
57+
fmt.Errorf(
58+
"invalid client_id format: must be a valid UUID (e.g., 550e8400-e29b-41d4-a716-446655440000)",
59+
),
60+
h.logger,
61+
)
62+
return
63+
}
64+
clientID = &parsed
65+
}
66+
4867
// Parse optional created_at_from query parameter
4968
var createdAtFrom *time.Time
5069
if fromStr := c.Query("created_at_from"); fromStr != "" {
@@ -88,6 +107,7 @@ func (h *AuditLogHandler) ListHandler(c *gin.Context) {
88107
limit+1,
89108
createdAtFrom,
90109
createdAtTo,
110+
clientID,
91111
)
92112
if err != nil {
93113
httputil.HandleErrorGin(c, err, h.logger)

internal/auth/http/audit_log_handler_test.go

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
6868
}
6969

7070
mockUseCase.EXPECT().
71-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)).
71+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
7272
Return(expectedAuditLogs, nil).
7373
Once()
7474

@@ -97,7 +97,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
9797
expectedAuditLogs := []*authDomain.AuditLog{}
9898

9999
mockUseCase.EXPECT().
100-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 101, (*time.Time)(nil), (*time.Time)(nil)).
100+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 101, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
101101
Return(expectedAuditLogs, nil).
102102
Once()
103103

@@ -119,7 +119,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
119119
expectedAuditLogs := []*authDomain.AuditLog{}
120120

121121
mockUseCase.EXPECT().
122-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)).
122+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
123123
Return(expectedAuditLogs, nil).
124124
Once()
125125

@@ -140,7 +140,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
140140

141141
// Mock expectation - handler will proceed with defaults since offset is not a valid parameter
142142
mockUseCase.EXPECT().
143-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)).
143+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
144144
Return([]*authDomain.AuditLog{}, nil).
145145
Once()
146146

@@ -156,7 +156,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
156156

157157
// Mock expectation - handler will proceed with defaults since offset is not a valid parameter
158158
mockUseCase.EXPECT().
159-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)).
159+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
160160
Return([]*authDomain.AuditLog{}, nil).
161161
Once()
162162

@@ -187,7 +187,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
187187

188188
// Mock usecase to expect clamped limit of 1000
189189
mockUseCase.EXPECT().
190-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 1001, (*time.Time)(nil), (*time.Time)(nil)).
190+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 1001, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
191191
Return([]*authDomain.AuditLog{}, nil).
192192
Once()
193193

@@ -217,7 +217,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
217217
handler, mockUseCase := setupTestAuditLogHandler(t)
218218

219219
mockUseCase.EXPECT().
220-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil)).
220+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), (*uuid.UUID)(nil)).
221221
Return(nil, errors.New("database error")).
222222
Once()
223223

@@ -256,7 +256,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
256256
}
257257

258258
mockUseCase.EXPECT().
259-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, (*time.Time)(nil)).
259+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, (*time.Time)(nil), (*uuid.UUID)(nil)).
260260
Return(expectedAuditLogs, nil).
261261
Once()
262262

@@ -300,7 +300,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
300300
}
301301

302302
mockUseCase.EXPECT().
303-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), &createdAtTo).
303+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), &createdAtTo, (*uuid.UUID)(nil)).
304304
Return(expectedAuditLogs, nil).
305305
Once()
306306

@@ -345,7 +345,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
345345
}
346346

347347
mockUseCase.EXPECT().
348-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, &createdAtTo).
348+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFrom, &createdAtTo, (*uuid.UUID)(nil)).
349349
Return(expectedAuditLogs, nil).
350350
Once()
351351

@@ -381,7 +381,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
381381
expectedAuditLogs := []*authDomain.AuditLog{}
382382

383383
mockUseCase.EXPECT().
384-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFromUTC, (*time.Time)(nil)).
384+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &createdAtFromUTC, (*time.Time)(nil), (*uuid.UUID)(nil)).
385385
Return(expectedAuditLogs, nil).
386386
Once()
387387

@@ -469,7 +469,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
469469
expectedAuditLogs := []*authDomain.AuditLog{}
470470

471471
mockUseCase.EXPECT().
472-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &now, &now).
472+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, &now, &now, (*uuid.UUID)(nil)).
473473
Return(expectedAuditLogs, nil).
474474
Once()
475475

@@ -499,7 +499,7 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
499499
expectedAuditLogs := []*authDomain.AuditLog{}
500500

501501
mockUseCase.EXPECT().
502-
ListCursor(mock.Anything, (*uuid.UUID)(nil), 26, &createdAtFrom, (*time.Time)(nil)).
502+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 26, &createdAtFrom, (*time.Time)(nil), (*uuid.UUID)(nil)).
503503
Return(expectedAuditLogs, nil).
504504
Once()
505505

@@ -518,4 +518,64 @@ func TestAuditLogHandler_ListHandler(t *testing.T) {
518518
assert.NoError(t, err)
519519
assert.Len(t, response.Data, 0)
520520
})
521+
522+
t.Run("Success_WithClientIDFilter", func(t *testing.T) {
523+
handler, mockUseCase := setupTestAuditLogHandler(t)
524+
525+
clientID := uuid.New()
526+
id := uuid.Must(uuid.NewV7())
527+
528+
expectedAuditLogs := []*authDomain.AuditLog{
529+
{
530+
ID: id,
531+
RequestID: uuid.Must(uuid.NewV7()),
532+
ClientID: clientID,
533+
Capability: authDomain.ReadCapability,
534+
Path: "/v1/secrets/test",
535+
CreatedAt: time.Now().UTC(),
536+
},
537+
}
538+
539+
mockUseCase.EXPECT().
540+
ListCursor(mock.Anything, (*uuid.UUID)(nil), 51, (*time.Time)(nil), (*time.Time)(nil), &clientID).
541+
Return(expectedAuditLogs, nil).
542+
Once()
543+
544+
c, w := createTestContext(
545+
http.MethodGet,
546+
"/v1/audit-logs?client_id="+clientID.String(),
547+
nil,
548+
)
549+
550+
handler.ListHandler(c)
551+
552+
assert.Equal(t, http.StatusOK, w.Code)
553+
554+
var response dto.ListAuditLogsResponse
555+
err := json.Unmarshal(w.Body.Bytes(), &response)
556+
assert.NoError(t, err)
557+
assert.Len(t, response.Data, 1)
558+
assert.Equal(t, id.String(), response.Data[0].ID)
559+
assert.Equal(t, clientID.String(), response.Data[0].ClientID)
560+
})
561+
562+
t.Run("Error_InvalidClientIDFormat", func(t *testing.T) {
563+
handler, _ := setupTestAuditLogHandler(t)
564+
565+
c, w := createTestContext(
566+
http.MethodGet,
567+
"/v1/audit-logs?client_id=invalid-uuid",
568+
nil,
569+
)
570+
571+
handler.ListHandler(c)
572+
573+
assert.Equal(t, http.StatusBadRequest, w.Code)
574+
575+
var response map[string]interface{}
576+
err := json.Unmarshal(w.Body.Bytes(), &response)
577+
assert.NoError(t, err)
578+
assert.Equal(t, "bad_request", response["error"])
579+
assert.Contains(t, response["message"], "invalid client_id format")
580+
})
521581
}

0 commit comments

Comments
 (0)