-
Notifications
You must be signed in to change notification settings - Fork 0
Description
In v0.17.0 have no email and related claims for audit logging, want email and other audit logging info. DD gives us this only:
{
"duration_ms": 16,
"level": "ERROR",
"time": "2026-02-03T21:30:49.721158423Z",
"procedure": "/holos.console.v1.SecretsService/UpdateSecret",
"error": "permission_denied: RBAC: authorization denied"
}- Ensure we have one struct or proto for audit log messages.
- Ensure we have a helper function to log permission denied errors like this with the struct or proto schema.
- Review all write operations and ensure permission denied code paths are audit logged.
Design
Problem
The LoggingInterceptor in console/rpc/logging.go logs all RPC calls but includes no identity information (email, sub, roles). This is because it sits outside the auth interceptor in the chain — by the time it logs, claims have not been injected into its context. Handler-level audit logs exist but are inconsistent: some include roles, some don't, and there is no standardized struct or helper.
Root Cause
Interceptor ordering in console/console.go (lines 205-209):
connect.WithInterceptors(
rpc.MetricsInterceptor(), // outermost
rpc.LoggingInterceptor(), // middle — no claims in context
rpc.LazyAuthInterceptor(...), // innermost — sets claims in context
)LoggingInterceptor calls next(ctx, req) which invokes the auth interceptor. The auth interceptor creates a new context with claims, but that context is only visible to the handler. LoggingInterceptor still holds the original context without claims.
Solution
Two changes:
-
Reorder interceptors so
LoggingInterceptorruns afterLazyAuthInterceptor, giving it access to claims in context. Unauthenticated request failures are already captured by HTTP-level request logging (console/console.go:477-506) and Prometheus metrics, so no observability is lost. -
Create a standardized audit logging helper (
console/rpc/audit.go) with a struct and functions that all handlers use for permission denied and access granted logs. This ensures consistent fields across all 17 permission denied code paths.
Interceptor Chain (After)
connect.WithInterceptors(
rpc.MetricsInterceptor(), // outermost — records latency + error codes
rpc.LazyAuthInterceptor(...), // sets claims in context
rpc.LoggingInterceptor(), // innermost — now has claims, logs with identity
)Audit Log Schema
All audit log entries will include these fields when available:
| Field | Source | Description |
|---|---|---|
procedure |
Request spec | RPC method (e.g., /holos.console.v1.SecretsService/UpdateSecret) |
duration_ms |
Timer | Request duration |
action |
Handler | Operation identifier (e.g., secret_update_denied) |
resource_type |
Handler | "secret", "project", or "organization" |
resource |
Handler | Resource name |
project |
Handler | Project scope (secrets/projects) |
organization |
Handler | Organization scope (orgs/projects) |
sub |
Claims | OIDC subject identifier |
email |
Claims | User email |
name |
Claims | User display name |
roles |
Claims | OIDC role memberships |
error |
Error | Error message (on failure) |
Current State: Permission Denied Logging Inventory
17 deny paths across 3 services. Inconsistencies:
| Service | Operations with deny logs | Missing roles field |
|---|---|---|
| Secrets | 5 of 7 (List has no deny) | secret_create_denied, secret_update_denied, sharing_update_denied |
| Organizations | 6 of 7 (List has no deny) | All 6 |
| Projects | 7 of 7 (List has no deny) | All 7 |
After this change, all 17 deny paths will use the same helper with all identity fields.
Implementation Plan
Phase 1: Audit Helper (RED → GREEN)
RED — Write failing tests in console/rpc/audit_test.go:
TestLogDenied— callsLogDeniedwith anAuditEvent, captures slog output, asserts all fields present at Warn level.TestLogAllowed— callsLogAllowedwith anAuditEvent, captures slog output, asserts all fields present at Info level.TestLogDenied_NilClaims— when claims are nil, logs with empty identity fields (no panic).TestAuditAttrs— verifies the slog attributes match the schema above.
GREEN — Create console/rpc/audit.go:
-
Define
AuditEventstruct:type AuditEvent struct { Action string ResourceType string Resource string Project string // optional Organization string // optional }
-
LogDenied(ctx context.Context, event AuditEvent)— extracts claims from context viaClaimsFromContext, logs atslog.WarnContextwith all schema fields. -
LogAllowed(ctx context.Context, event AuditEvent)— same butslog.InfoContext. -
Internal helper
claimsAttrs(claims *Claims) []slog.Attr— returns sub, email, name, roles attrs (handles nil claims gracefully).
Phase 2: Enhanced LoggingInterceptor (RED → GREEN)
RED — Update console/rpc/logging_test.go:
- Test that
LoggingInterceptorincludesemail,sub, androlesin log output when claims are in context. - Test that
LoggingInterceptorstill works when claims are absent (graceful degradation).
GREEN — Modify console/rpc/logging.go:
- After
resp, err := next(ctx, req), extract claims from context viaClaimsFromContext(ctx). - If claims are present, append
email,sub,name,rolesto the log attrs. - No behavior change otherwise.
Reorder interceptors in console/console.go:
Change protected interceptor order so LoggingInterceptor is innermost:
protectedInterceptors = connect.WithInterceptors(
rpc.MetricsInterceptor(),
rpc.LazyAuthInterceptor(s.cfg.Issuer, s.cfg.ClientID, s.cfg.RolesClaim, internalClient),
rpc.LoggingInterceptor(),
)Phase 3: Migrate Handler Audit Logs (RED → GREEN)
RED — Write or update tests for each service to verify audit log output uses consistent fields:
- Secrets handler tests: verify
LogDeniedcalled with correctAuditEventfor all 5 deny paths. - Organizations handler tests: verify for all 6 deny paths.
- Projects handler tests: verify for all 7 deny paths.
GREEN — Replace all 17 inline slog.WarnContext deny logs with rpc.LogDenied:
Secrets (console/secrets/handler.go):
secret_delete_denied(line 166) →rpc.LogDenied(ctx, rpc.AuditEvent{Action: "secret_delete_denied", ResourceType: "secret", Resource: name, Project: project})secret_create_denied(line 230) → same patternsecret_update_denied(line 312) → same patternsharing_update_denied(line 380) → same patternsecret_access_denied(line 678) → same pattern
Organizations (console/organizations/handler.go):
organization_read_denied(line 118)organization_create_denied(line 160)organization_update_denied(line 219)organization_delete_denied(line 270)organization_sharing_denied(line 332)organization_raw_denied(line 395)
Projects (console/projects/handler.go):
project_read_denied(line 117)project_create_denied(line 168)project_update_denied(line 233)project_delete_denied(line 287)project_sharing_denied(line 341)project_raw_denied(line 407)
Also migrate the corresponding LogAllowed paths (success logs) for consistency. Remove the logAuditAllowed and logAuditDenied helper functions in console/secrets/handler.go since they are replaced by rpc.LogDenied / rpc.LogAllowed.
Phase 4: Final Cleanup
- Remove
logAuditAllowedandlogAuditDeniedfromconsole/secrets/handler.go. - Remove unused imports from all modified files.
- Scan for any remaining inline audit logs that should use the helper.
- Verify
make generate && make test && make lintall pass.
TODO
- Phase 1: Audit helper struct and functions
- Phase 2: Enhanced LoggingInterceptor with claims + interceptor reorder
- Phase 3: Migrate all 17 deny paths + success paths to use audit helper
- Phase 4: Final cleanup