Skip to content

Add email and related claims to permission denied errors #158

@jeffmccune

Description

@jeffmccune

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"
}
  1. Ensure we have one struct or proto for audit log messages.
  2. Ensure we have a helper function to log permission denied errors like this with the struct or proto schema.
  3. 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:

  1. Reorder interceptors so LoggingInterceptor runs after LazyAuthInterceptor, 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.

  2. 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 — calls LogDenied with an AuditEvent, captures slog output, asserts all fields present at Warn level.
  • TestLogAllowed — calls LogAllowed with an AuditEvent, 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:

  1. Define AuditEvent struct:

    type AuditEvent struct {
        Action       string
        ResourceType string
        Resource     string
        Project      string   // optional
        Organization string   // optional
    }
  2. LogDenied(ctx context.Context, event AuditEvent) — extracts claims from context via ClaimsFromContext, logs at slog.WarnContext with all schema fields.

  3. LogAllowed(ctx context.Context, event AuditEvent) — same but slog.InfoContext.

  4. 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 LoggingInterceptor includes email, sub, and roles in log output when claims are in context.
  • Test that LoggingInterceptor still works when claims are absent (graceful degradation).

GREEN — Modify console/rpc/logging.go:

  1. After resp, err := next(ctx, req), extract claims from context via ClaimsFromContext(ctx).
  2. If claims are present, append email, sub, name, roles to the log attrs.
  3. 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 LogDenied called with correct AuditEvent for 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 pattern
  • secret_update_denied (line 312) → same pattern
  • sharing_update_denied (line 380) → same pattern
  • secret_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

  1. Remove logAuditAllowed and logAuditDenied from console/secrets/handler.go.
  2. Remove unused imports from all modified files.
  3. Scan for any remaining inline audit logs that should use the helper.
  4. Verify make generate && make test && make lint all 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions