Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8256786
chore(conductor): Add new track 'Add Audit Log Filtering by Client'
allisson Mar 8, 2026
97bee6d
feat(auth): add clientID filter to AuditLogRepository and AuditLogUse…
allisson Mar 8, 2026
8501e96
feat(auth): implement clientID filter in PostgreSQL and MySQL AuditLo…
allisson Mar 8, 2026
c606ac9
feat(database): add index for client_id on audit_logs table
allisson Mar 8, 2026
a640ed9
conductor(checkpoint): Checkpoint end of Phase 1
allisson Mar 8, 2026
f9e19f2
conductor(plan): Mark Phase 1 as complete
allisson Mar 8, 2026
991c9dd
feat(auth): implement clientID filter in AuditLogUseCase and metrics …
allisson Mar 8, 2026
b9f7b38
conductor(checkpoint): Checkpoint end of Phase 2
allisson Mar 8, 2026
910b045
conductor(plan): Mark Phase 2 as complete
allisson Mar 8, 2026
4ef8ee2
feat(auth): implement client_id filter in AuditLogHandler
allisson Mar 8, 2026
c2c5e8a
conductor(checkpoint): Checkpoint end of Phase 3
allisson Mar 8, 2026
fbb289e
conductor(plan): Mark Phase 3 as complete
allisson Mar 8, 2026
bf60d39
docs(audit): document client_id filter and add integration tests
allisson Mar 8, 2026
def8bbe
conductor(checkpoint): Checkpoint end of Phase 4
allisson Mar 8, 2026
133e755
conductor(plan): Mark Phase 4 as complete
allisson Mar 8, 2026
d51a830
chore(conductor): Mark track 'Add Audit Log Filtering by Client' as c…
allisson Mar 8, 2026
a6415fc
docs(conductor): Synchronize docs for track 'Add Audit Log Filtering …
allisson Mar 8, 2026
b8ae7a5
chore(conductor): Archive track 'Add Audit Log Filtering by Client'
allisson Mar 8, 2026
7bbb2f9
feat(auth): implement audit log filtering by client_id
allisson Mar 9, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Track audit_log_filtering_by_client_20260307 Context

- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
Original file line number Diff line number Diff line change
@@ -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"
}
39 changes: 39 additions & 0 deletions conductor/archive/audit_log_filtering_by_client_20260307/plan.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions conductor/archive/audit_log_filtering_by_client_20260307/spec.md
Original file line number Diff line number Diff line change
@@ -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=<uuid>` 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.
2 changes: 1 addition & 1 deletion conductor/product.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions conductor/tracks.md
Original file line number Diff line number Diff line change
@@ -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.

---
1 change: 1 addition & 0 deletions docs/observability/audit-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion internal/auth/http/audit_log_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,11 +33,12 @@ func NewAuditLogHandler(
}

// ListHandler retrieves audit logs with cursor pagination and optional time-based filtering.
// 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
// 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>
// 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")
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 73 additions & 13 deletions internal/auth/http/audit_log_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand All @@ -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")
})
}
11 changes: 11 additions & 0 deletions internal/auth/repository/mysql/mysql_audit_log_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,15 @@ 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(
ctx context.Context,
afterID *uuid.UUID,
limit int,
createdAtFrom, createdAtTo *time.Time,
clientID *uuid.UUID,
) ([]*authDomain.AuditLog, error) {
querier := database.GetTx(ctx, m.db)

Expand Down Expand Up @@ -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`
Expand Down
Loading
Loading