Skip to content

[WIP] feat: add audit logging for security events#448

Open
Aloui-Ikram wants to merge 7 commits into
mainfrom
feat/audit-logging-238
Open

[WIP] feat: add audit logging for security events#448
Aloui-Ikram wants to merge 7 commits into
mainfrom
feat/audit-logging-238

Conversation

@Aloui-Ikram
Copy link
Copy Markdown
Member

@Aloui-Ikram Aloui-Ikram commented May 17, 2026

Closes #238.

Summary

Adds a dedicated structured audit log on both Ground Control and Satellite for security-relevant events. Output is one JSON document per line with size-, age, and count-based rotation, intended for direct ingestion by SIEM
tools (Filebeat → Elasticsearch, Wazuh, Splunk).

The two sides ship as separate Go modules and therefore each carry their own copy of the logger; the shared contract is the event schema documented indocs/guides/audit-logging.md.

Event schema

{
  "event_id":   "<uuid v4>",
  "timestamp":  "<RFC3339 UTC, nanosecond precision>",
  "event_type": "<dotted lowercase name>",
  "actor":      "<who, or empty>",
  "source_ip":  "<ip, or empty>",
  "details":    { "...": "event-specific" }
}

event_id, timestamp, and event_type are always present. details is omitted when empty.

Implementation overview

  • New package internal/logger (Satellite) and ground-control/internal/logger (Ground Control). Identical surface; intentional duplication justified by the two-module layout.
  • AuditLogger is a thin wrapper over zerolog writing to a gopkg.in/natefinch/lumberjack.v2 rotator. When disabled it writes to io.Discard, making audit.Log(...) safe to call unconditionally from every emit site.
  • WithAuditLogger / AuditFromContext helpers carry the logger through request-scoped contexts in the Ground Control HTTP stack.
  • HTTP middleware (ground-control/internal/server/middleware.go) captures the client IP (X-Forwarded-ForX-Real-IPRemoteAddr) and attaches both the logger and IP to the request context.

Files

Area Files
Logger (Satellite) internal/logger/audit.go, internal/logger/audit_test.go
Logger (Ground Control) ground-control/internal/logger/audit.go, ground-control/internal/logger/audit_test.go
Config (Satellite) pkg/config/{config,constants,getters,validate,validate_test}.go, config.example.json
Config (Ground Control) ground-control/.env.example
Wiring cmd/main.go, ground-control/internal/server/{server,middleware}.go, internal/state/registration_process.go
Event emitters (Ground Control) ground-control/internal/server/{auth,user,satellite,config}_handlers.go
Docs docs/guides/audit-logging.md
Dependency go.mod, go.sum (both modules)

24 files, ~887 lines added, one new dependency (lumberjack.v2).

Event types

Status Event type Emitted from
Implemented user.login.success GC
Implemented user.login.failure GC
Implemented user.created GC
Implemented user.deleted GC
Implemented user.password_changed GC
Implemented satellite.registered GC + Satellite
Implemented satellite.deregistered GC
Implemented satellite.auth.failure GC
Implemented config.changed GC + Satellite
Reserved satellite.revoked
Reserved satellite.unrevoked
Reserved policy.pull_blocked

Reserved constants are defined in the logger package so follow-up PRs (revoke handlers, registry-level policy hooks) can wire them up without changing the logger surface. See the roadmap section of docs/guides/audit-logging.md.

Configuration

Satellite : app_config.audit block in config.json:

"audit": {
  "enabled": true,
  "file_path": "/var/log/harbor-satellite/audit.log",
  "max_size_mb": 100,
  "max_backups": 7,
  "max_age_days": 30
}

Ground Control : environment variables:

AUDIT_LOG_ENABLED=true
AUDIT_LOG_PATH=/var/log/ground-control/audit.log
AUDIT_LOG_MAX_SIZE_MB=100
AUDIT_LOG_MAX_BACKUPS=7
AUDIT_LOG_MAX_AGE_DAYS=30

Both sides default to disabled. Defaults for path, rotation size, backup count, and retention are identical on both sides.

Validation: when enabled is true, file_path must be non-empty and the rotation values must be non-negative. The process refuses to start on invalid config rather than silently dropping events.

Verification

Unit tests

go test ./... passes in both modules. 14 new tests across the two logger
packages cover: JSON output shape, required-field presence, RFC3339 timestamp
format, UUID uniqueness across calls, no-op behaviour when disabled and when
called on a nil receiver, and context round-trip via WithAuditLogger /
AuditFromContext.

End-to-end run

Executed against a local stack: Harbor v2.11.1, Ground Control (this branch),
Satellite (this branch), Postgres 16. Audit enabled on both sides, paths set to
/tmp/audit-logs/gc-audit.log and /tmp/audit-logs/satellite-audit.log.

Each event type was exercised via a real API call (no synthetic generators).

Captured Ground Control events

{
  "event_id": "86ecf39e-40e4-46de-90d0-80e9900e77eb",
  "timestamp": "2026-05-17T15:02:01Z",
  "event_type": "user.login.success",
  "actor": "admin",
  "source_ip": "::1"
}
{
  "event_id": "77524874-81bb-4885-944e-9e754fb21a0a",
  "timestamp": "2026-05-17T15:12:57Z",
  "event_type": "user.login.failure",
  "actor": "admin",
  "source_ip": "::1",
  "details": { "reason": "bad_password" }
}
{
  "event_id": "261473a3-4445-4469-b277-4500b433877e",
  "timestamp": "2026-05-17T15:18:19Z",
  "event_type": "user.created",
  "actor": "admin",
  "source_ip": "::1",
  "details": { "role": "admin", "target_user": "demo" }
}
{
  "event_id": "8bb311ce-170a-4787-ab1d-6ef7b4bdcdef",
  "timestamp": "2026-05-17T15:24:22Z",
  "event_type": "user.password_changed",
  "actor": "admin",
  "source_ip": "::1",
  "details": { "flow": "admin_reset", "target_user": "demo" }
}
{
  "event_id": "2f612367-85e0-4c89-9156-da0a719a16d1",
  "timestamp": "2026-05-17T15:31:41Z",
  "event_type": "config.changed",
  "actor": "admin",
  "source_ip": "::1",
  "details": { "action": "create", "config_name": "default" }
}
{
  "event_id": "7c61a5df-a2a8-41d0-8fec-7cd6e14c2a67",
  "timestamp": "2026-05-17T15:32:00Z",
  "event_type": "satellite.registered",
  "actor": "sat-demo-01",
  "source_ip": "::1",
  "details": { "config_name": "default", "flow": "token", "groups": null }
}
{
  "event_id": "9483ecb9-e686-4f24-960a-7455ef803054",
  "timestamp": "2026-05-17T15:44:31Z",
  "event_type": "user.deleted",
  "actor": "admin",
  "source_ip": "::1",
  "details": { "target_user": "demo" }
}
{
  "event_id": "8a4a5e08-5382-4f6f-82e6-ddff9924cae4",
  "timestamp": "2026-05-17T15:45:42Z",
  "event_type": "satellite.auth.failure",
  "actor": "",
  "source_ip": "::1",
  "details": { "masked_token": "this...oken", "reason": "invalid_token" }
}
{
  "event_id": "4162ba9d-d66e-4fd2-ad1a-76f2e0d63518",
  "timestamp": "2026-05-17T15:55:22Z",
  "event_type": "satellite.registered",
  "actor": "sat-demo-02",
  "source_ip": "127.0.0.1",
  "details": { "flow": "ztr" }
}

Sensitive material is redacted before emission: satellite.auth.failure
records masked_token (first four and last four characters only), never the
full invalid token.

Captured Satellite events

{
  "event_id": "1b94cad2-18b2-47e6-8091-3f3fbe32227c",
  "timestamp": "2026-05-17T15:55:22.38776313Z",
  "event_type": "satellite.registered",
  "actor": "http://localhost:8080",
  "source_ip": "",
  "details": { "flow": "ztr" }
}
{
  "event_id": "7e5b0b00-3c32-4b3a-94c4-dbb7dd4dbe40",
  "timestamp": "2026-05-17T15:55:22.387929188Z",
  "event_type": "config.changed",
  "actor": "satellite",
  "source_ip": "",
  "details": { "changed_keys": ["zot_config"], "source": "hot_reload" }
}

Coverage matrix

Event type Exercised in this run
user.login.success Yes (GC)
user.login.failure Yes (GC)
user.created Yes (GC)
user.deleted Yes (GC)
user.password_changed Yes (GC)
satellite.registered Yes (GC + Satellite)
satellite.auth.failure Yes (GC)
config.changed Yes (GC + Satellite)
satellite.deregistered Not exercised — see note below

Known limitation

satellite.deregistered could not be exercised in the local test environment.
The deregister handler attempts to delete the satellite's Harbor repository
before the audit emit fires; when the repository was never written to, Harbor
returns 404 and the handler exits early with HTTP 500. This is a pre-existing
behaviour of the deregister handler, not a defect introduced by this PR; the
audit emit on the success path is wired identically to the other satellite
events.

Documentation

docs/guides/audit-logging.md documents the schema, every event type and its
details payload, both configuration mechanisms, and an integration recipe for
Filebeat → Elasticsearch.

Summary by CodeRabbit

  • New Features
    • Added structured audit logging for auth, user lifecycle, config changes, and satellite events; runtime reconfiguration on hot-reload; disabled by default with configurable file rotation and optional trusting of forwarded client IPs.
  • Documentation
    • Added full audit logging guide with event catalogue, format, config examples, PII and SIEM guidance, and roadmap.
  • Tests
    • Added unit tests for emission, sanitization, uniqueness, no-op behavior, reconfiguration, and config validation.
  • Chores
    • Added/updated config examples and environment variables; dependency updates for log rotation.

Implements structured JSON audit logging across satellite and
ground-control for authentication, satellite lifecycle, user management,
and configuration changes. Events are written one-per-line with
size/age/backup rotation via lumberjack, suitable for SIEM ingestion
(Filebeat/ELK, Wazuh, Splunk).

Event schema (one per line):

  {"event_id":"<uuid>","timestamp":"<RFC3339Z>","event_type":"<dotted>",
   "actor":"<who>","source_ip":"<ip>","details":{...}}

Event types implemented:
- user.login.success / user.login.failure
- user.created / user.deleted / user.password_changed
- satellite.registered / satellite.deregistered / satellite.auth.failure
- config.changed (GC API + satellite hot-reload)

Reserved (constants defined, not yet emitted; see roadmap section of
docs/guides/audit-logging.md):
- satellite.revoked / satellite.unrevoked - pending revoke handlers in GC
- policy.pull_blocked - pending registry-level policy hooks

Configuration:
- Satellite: app_config.audit.{enabled, file_path, max_size_mb,
  max_backups, max_age_days}
- Ground Control: AUDIT_LOG_ENABLED / AUDIT_LOG_PATH / AUDIT_LOG_MAX_*
  env vars
- Defaults: disabled, ./audit.log, 100 MB, 7 backups, 30 days

Verified end-to-end:
- Unit tests green in both modules (go test ./...)
- E2E run against real Harbor v2.11.1 + Postgres + GC + satellite
- 11 events captured live across 8 distinct event types

Fixes #238

Signed-off-by: aloui ikram <ikram@container-registry.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation golang labels May 17, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c17c9b53-0e8d-47da-b53b-876bf33d850b

📥 Commits

Reviewing files that changed from the base of the PR and between 5862852 and e7e13c1.

📒 Files selected for processing (6)
  • cmd/main.go
  • ground-control/internal/logger/audit.go
  • internal/logger/audit.go
  • pkg/config/manager.go
  • pkg/config/manager_test.go
  • pkg/config/validate.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • cmd/main.go
  • pkg/config/validate.go
  • internal/logger/audit.go
  • ground-control/internal/logger/audit.go

📝 Walkthrough

Walkthrough

Adds a structured, file-backed audit logger (with rotation and runtime reconfigure) and instruments Satellite and Ground Control to emit typed audit events for auth, user, satellite registration/deregistration, and config changes; includes config types, validation, tests, docs, and example env/config updates.

Changes

Audit Logging Implementation

Layer / File(s) Summary
Audit logger core and wiring
internal/logger/audit.go, ground-control/internal/logger/audit.go
Defines AuditEventType, AuditConfig, and AuditLogger with NewAuditLogger/Reconfigure using lumberjack+zerolog, ensureWritable checks, atomic swap semantics, Log emits structured one-line JSON (event_id, UTC timestamp, event_type, actor, source_ip, optional details), Enabled() and context helpers.
Audit logger tests and helpers
internal/logger/audit_test.go, ground-control/internal/logger/audit_test.go
Unit tests cover structured emission, RFC3339Nano timestamps, unique event_ids, disabled/no-op behavior, empty-path handling, constructor errors for unwritable destinations, Reconfigure hot-swap behavior, nil-receiver safety, and context round-trip.
Config contracts, defaults, and validation
pkg/config/config.go, pkg/config/constants.go, pkg/config/getters.go, pkg/config/validate.go, pkg/config/validate_test.go, config.example.json, ground-control/.env.example
Adds exported AuditConfig to AppConfig, default constants for path/rotation, pointer-backed optional rotation fields, GetAuditConfig() getter, validation/defaulting helpers that apply defaults/warnings only when enabled, AuditConfig.Equal, and tests for defaulting and equality semantics. Example config/env updated.
Satellite init, hot-reload, and registration
cmd/main.go, internal/state/registration_process.go, internal/state/registration_process_test.go
run initializes and injects the audit logger from ConfigManager, tracks current audit config, and on hot-reload calls helper to emit EventConfigChanged and reconfigure the logger in-place. ZTR registration emits sanitized EventSatelliteAuthFailure on errors and EventSatelliteRegistered on success; sanitizeAuditReason redacts tokens and is unit-tested.
Ground Control server init and middleware
ground-control/internal/server/server.go, ground-control/internal/server/middleware.go
Server adds audit *auditlog.AuditLogger and trustForwardedHeaders; NewServer builds audit logger via loadAuditConfig() from AUDIT_LOG_* env vars and validates integer envs; middleware derives client IP (honoring forwarded headers when enabled) and auditEvent() wrapper logs events with computed source_ip.
Handlers instrumentation
ground-control/internal/server/auth_handlers.go, ground-control/internal/server/user_handlers.go, ground-control/internal/server/satellite_handlers.go, ground-control/internal/server/config_handlers.go
Auth handlers emit EventUserLoginSuccess/EventUserLoginFailure with specific reasons; user handlers emit EventUserCreated/EventUserDeleted/EventUserPasswordChanged (self/admin flows) using actor from context; satellite handlers emit EventSatelliteRegistered/EventSatelliteDeregistered and EventSatelliteAuthFailure for token/ZTR/SPIFFE failures with masked tokens/IDs; config handlers emit EventConfigChanged after commits.
Documentation and dependency updates
docs/guides/audit-logging.md, go.mod, ground-control/go.mod, ground-control/.env.example
Adds guide describing JSON-per-line audit event schema, event catalogue, configuration for Satellite and Ground Control, operational notes on PII masking and SIEM forwarding. Adds gopkg.in/natefinch/lumberjack.v2 and bumps github.com/rs/zerolog and github.com/google/uuid in module files.

Sequence Diagrams

sequenceDiagram
  participant Client
  participant AuthHandler
  participant Server
  participant AuditLogger
  participant File
  Client->>AuthHandler: POST /login (username,password)
  AuthHandler->>AuthHandler: validate credentials
  alt success
    AuthHandler->>Server: s.auditEvent(EventUserLoginSuccess,...)
    Server->>AuditLogger: Log(EventUserLoginSuccess, actor, source_ip, details)
    AuditLogger->>File: write JSON line (event_id,timestamp,...)
    AuthHandler->>Client: 200
  else failure
    AuthHandler->>Server: s.auditEvent(EventUserLoginFailure,{reason})
    Server->>AuditLogger: Log(EventUserLoginFailure, actor, source_ip, details)
    AuditLogger->>File: write JSON line
    AuthHandler->>Client: 401
  end
Loading
sequenceDiagram
  participant SatelliteHandler
  participant RegistrationDB
  participant Server
  participant AuditLogger
  participant File
  SatelliteHandler->>SatelliteHandler: validate token/ZTR/SPIFFE
  alt validation fails
    SatelliteHandler->>Server: auditEvent(EventSatelliteAuthFailure,{reason,masked})
    Server->>AuditLogger: Log(EventSatelliteAuthFailure, actor, source_ip, details)
    AuditLogger->>File: write JSON line
  else registration succeeds
    SatelliteHandler->>RegistrationDB: persist registration
    RegistrationDB-->>SatelliteHandler: commit
    SatelliteHandler->>Server: auditEvent(EventSatelliteRegistered,{flow,config})
    Server->>AuditLogger: Log(EventSatelliteRegistered, actor, source_ip, details)
    AuditLogger->>File: write JSON line
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • bupd
  • Vad1mo
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly describes the main change: adding audit logging for security events, which is the core objective of this substantial changeset across both Satellite and Ground Control.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering summary, event schema, implementation overview, file organization, event types, configuration, verification, and documentation—exceeding the template requirements.
Linked Issues check ✅ Passed The PR implements all coding requirements from issue #238: audit logger packages (Satellite & GC), event schema, configuration, handler instrumentation, rotation, unique IDs, RFC3339 timestamps, and documentation.
Out of Scope Changes check ✅ Passed All code changes directly support audit logging: logger implementations, config handling, handler instrumentation, middleware enhancements, dependency management, and documentation. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/audit-logging-238

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 142 complexity · 6 duplication

Metric Results
Complexity 142
Duplication 6

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@Aloui-Ikram Aloui-Ikram added enhancement New feature or request component:satellite Satellite component component:ground-control Ground Control component security Security related compliance Compliance and auditing labels May 17, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
ground-control/internal/logger/audit.go (1)

73-81: ⚡ Quick win

Avoid writing empty optional fields.

Line 77 and Line 78 always emit actor and source_ip. For optional schema fields, omit them when empty to keep event payloads semantically clean.

Proposed patch
 	evt := a.log.Log().
 		Str("event_id", uuid.NewString()).
 		Time("timestamp", time.Now().UTC()).
-		Str("event_type", string(eventType)).
-		Str("actor", actor).
-		Str("source_ip", sourceIP)
+		Str("event_type", string(eventType))
+	if actor != "" {
+		evt = evt.Str("actor", actor)
+	}
+	if sourceIP != "" {
+		evt = evt.Str("source_ip", sourceIP)
+	}
 	if len(details) > 0 {
 		evt = evt.Interface("details", details)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ground-control/internal/logger/audit.go` around lines 73 - 81, The current
audit event builder always emits the optional fields "actor" and "source_ip";
update the event construction around the evt variable returned from a.log.Log()
so that you only call Str("actor", actor) and Str("source_ip", sourceIP) when
those values are non-empty (similar to the existing conditional for details),
e.g., check actor != "" and sourceIP != "" before adding each field to evt; keep
the uuid, timestamp and event_type fields as-is.
internal/logger/audit.go (1)

73-81: ⚡ Quick win

Only emit optional fields when present.

Line 77 and Line 78 currently always write actor and source_ip. Since these are optional fields, prefer omitting them when empty instead of logging empty strings.

Proposed patch
 	evt := a.log.Log().
 		Str("event_id", uuid.NewString()).
 		Time("timestamp", time.Now().UTC()).
-		Str("event_type", string(eventType)).
-		Str("actor", actor).
-		Str("source_ip", sourceIP)
+		Str("event_type", string(eventType))
+	if actor != "" {
+		evt = evt.Str("actor", actor)
+	}
+	if sourceIP != "" {
+		evt = evt.Str("source_ip", sourceIP)
+	}
 	if len(details) > 0 {
 		evt = evt.Interface("details", details)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/logger/audit.go` around lines 73 - 81, The code always emits actor
and source_ip as empty strings; update the audit logger construction in
internal/logger/audit.go so that after creating evt :=
a.log.Log().Str("event_id", uuid.NewString()).Time("timestamp",
time.Now().UTC()).Str("event_type", string(eventType)), you only call evt =
evt.Str("actor", actor) when actor != "" and only call evt =
evt.Str("source_ip", sourceIP) when sourceIP != "", keeping the existing
conditional for details untouched; reference the evt variable and the
a.log.Log() call to locate where to add these conditional Str(...) calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ground-control/.env.example`:
- Around line 33-36: Reorder the audit-related environment variable keys in the
.env.example so they are alphabetically ordered to satisfy dotenv-linter
UnorderedKey warnings: arrange AUDIT_LOG_MAX_AGE_DAYS, AUDIT_LOG_MAX_BACKUPS,
AUDIT_LOG_MAX_SIZE_MB, AUDIT_LOG_PATH (or whichever alphabetical order your
linter expects) replacing the current block containing AUDIT_LOG_PATH,
AUDIT_LOG_MAX_SIZE_MB, AUDIT_LOG_MAX_BACKUPS, AUDIT_LOG_MAX_AGE_DAYS to remove
the lint noise.

In `@ground-control/internal/server/middleware.go`:
- Around line 114-127: The clientIP function currently trusts X-Forwarded-For
unconditionally; change it so it only uses the first X-Forwarded-For entry when
the immediate peer (from net.SplitHostPort(r.RemoteAddr)) is a trusted proxy
(implement or call an isTrustedProxy(host) helper that checks against your
configured proxy CIDRs), and validate the candidate IP with net.ParseIP before
returning it; otherwise ignore X-Forwarded-For and return the parsed RemoteAddr
host (or RemoteAddr raw if SplitHostPort fails). Ensure to reference and update
clientIP, add or use isTrustedProxy, and use net.ParseIP to validate extracted
IPs.

In `@ground-control/internal/server/server.go`:
- Around line 302-318: loadAuditConfig currently calls parseIntEnv which
silently falls back to defaults, so when AUDIT_LOG_ENABLED is true you must
validate numeric env vars and fail startup on invalid values. Update either
parseIntEnv to return (int, error) or add a new helper (e.g., parseIntEnvOrFail)
and in loadAuditConfig call that for AUDIT_LOG_MAX_SIZE_MB,
AUDIT_LOG_MAX_BACKUPS and AUDIT_LOG_MAX_AGE_DAYS; if parsing fails, log a clear
error and exit (e.g., log.Fatalf or os.Exit(1)) instead of using defaults, then
populate and return auditlog.AuditConfig with the validated ints.

In `@internal/state/registration_process.go`:
- Around line 71-74: The audit log call is writing raw err.Error() (and gcURL)
which may contain the satellite token and leak secrets; replace the direct use
of err.Error() (and/or raw gcURL) in the audit.Log invocation with a sanitized
value. Locate the audit.Log call (using identifiers audit.Log and
logger.EventSatelliteAuthFailure in registration_process.go) and change the
"reason" to a redacted/sanitized string (e.g., a short generic message plus safe
error type or a call to a sanitize/redact helper that strips URL token
segments), and ensure any logged URLs (gcURL) are likewise sanitized before
being passed to audit.Log. Ensure you do not write the full original err.Error()
or unredacted gcURL to the audit map.

---

Nitpick comments:
In `@ground-control/internal/logger/audit.go`:
- Around line 73-81: The current audit event builder always emits the optional
fields "actor" and "source_ip"; update the event construction around the evt
variable returned from a.log.Log() so that you only call Str("actor", actor) and
Str("source_ip", sourceIP) when those values are non-empty (similar to the
existing conditional for details), e.g., check actor != "" and sourceIP != ""
before adding each field to evt; keep the uuid, timestamp and event_type fields
as-is.

In `@internal/logger/audit.go`:
- Around line 73-81: The code always emits actor and source_ip as empty strings;
update the audit logger construction in internal/logger/audit.go so that after
creating evt := a.log.Log().Str("event_id", uuid.NewString()).Time("timestamp",
time.Now().UTC()).Str("event_type", string(eventType)), you only call evt =
evt.Str("actor", actor) when actor != "" and only call evt =
evt.Str("source_ip", sourceIP) when sourceIP != "", keeping the existing
conditional for details untouched; reference the evt variable and the
a.log.Log() call to locate where to add these conditional Str(...) calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 019c88ad-23c5-40e6-ba9e-b903402ace63

📥 Commits

Reviewing files that changed from the base of the PR and between aef6e4e and 3ffd496.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • ground-control/go.sum is excluded by !**/*.sum
📒 Files selected for processing (22)
  • cmd/main.go
  • config.example.json
  • docs/guides/audit-logging.md
  • go.mod
  • ground-control/.env.example
  • ground-control/go.mod
  • ground-control/internal/logger/audit.go
  • ground-control/internal/logger/audit_test.go
  • ground-control/internal/server/auth_handlers.go
  • ground-control/internal/server/config_handlers.go
  • ground-control/internal/server/middleware.go
  • ground-control/internal/server/satellite_handlers.go
  • ground-control/internal/server/server.go
  • ground-control/internal/server/user_handlers.go
  • internal/logger/audit.go
  • internal/logger/audit_test.go
  • internal/state/registration_process.go
  • pkg/config/config.go
  • pkg/config/constants.go
  • pkg/config/getters.go
  • pkg/config/validate.go
  • pkg/config/validate_test.go

Comment thread ground-control/.env.example Outdated
Comment thread ground-control/internal/server/middleware.go Outdated
Comment thread ground-control/internal/server/server.go
Comment thread internal/state/registration_process.go
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ground-control/internal/server/middleware.go">

<violation number="1" location="ground-control/internal/server/middleware.go:117">
P1: `clientIP` unconditionally trusts `X-Forwarded-For`, allowing any client to spoof `source_ip` in audit records. Only honor forwarded headers when `RemoteAddr` belongs to a trusted proxy; otherwise fall back to `RemoteAddr` directly. Without this, the audit trail's source-IP field is unreliable for incident investigation.</violation>

<violation number="2" location="ground-control/internal/server/middleware.go:123">
P2: Add `X-Real-IP` fallback before `RemoteAddr` so audit logs capture client IP correctly when `X-Forwarded-For` is absent.</violation>
</file>

<file name="ground-control/internal/server/server.go">

<violation number="1" location="ground-control/internal/server/server.go:314">
P2: Audit rotation env values are not validated; negative values are accepted and propagated to the logger config.</violation>
</file>

<file name="internal/state/registration_process.go">

<violation number="1" location="internal/state/registration_process.go:72">
P1: Raw `err.Error()` is written to the audit log as `reason`. If the HTTP call to the ZTR endpoint fails, the error may include the full request URL which contains the satellite token as a path segment. Redact or sanitize the error string before emitting it — the GC side already masks tokens in its auth-failure events.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread ground-control/internal/server/middleware.go Outdated
Comment thread internal/state/registration_process.go Outdated
Comment thread ground-control/internal/server/middleware.go
Comment thread ground-control/internal/server/server.go Outdated
- Sanitize errors before emitting to the audit log so the satellite
  token cannot leak via wrapped HTTP error messages from the ZTR call
  (internal/state/registration_process.go).
- Validate AUDIT_LOG_MAX_* env values on Ground Control; fail startup
  on negative or empty values instead of silently passing them through
  to lumberjack (ground-control/internal/server/server.go).
- Gate X-Forwarded-For / X-Real-IP behind a new opt-in env var
  AUDIT_TRUST_FORWARDED_HEADERS so audit source_ip cannot be spoofed
  by clients in default deployments. When the flag is set, the first
  X-Forwarded-For entry is preferred, then X-Real-IP, then RemoteAddr
  (ground-control/internal/server/middleware.go).
- Alphabetize audit keys in ground-control/.env.example.
- Document the new env var and validation behaviour in the guide.

Addresses bot review comments on PR #448.

Signed-off-by: aloui ikram <ikram@container-registry.com>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ground-control/internal/server/server.go">

<violation number="1" location="ground-control/internal/server/server.go:324">
P2: Invalid numeric audit env values are still silently accepted as defaults because `parseIntEnv` ignores parse errors.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread ground-control/internal/server/server.go Outdated
If an operator sets one of the audit rotation env vars to a non-integer
(e.g. AUDIT_LOG_MAX_SIZE_MB=abc), GC used to silently fall back to the
default. It now exits with a clear error instead.

Addresses cubic re-review on PR #448.

Signed-off-by: aloui ikram <ikram@container-registry.com>
@Aloui-Ikram
Copy link
Copy Markdown
Member Author

Thanks for the reviews. Summary of what changed since 3ffd496:

Commit 2bdc3b8

  • Sanitize errors before emitting to the audit log on the satellite ZTR path so the token cannot leak through wrapped HTTP error messages (internal/state/registration_process.go, plus a unit test for the redaction).
  • Validate the AUDIT_LOG_MAX_* env values on GC: empty path or negative numbers now cause log.Fatalf at startup instead of being passed through to lumberjack.
  • Make X-Forwarded-For / X-Real-IP opt-in via a new env var AUDIT_TRUST_FORWARDED_HEADERS (default false). The default behaviour is to use the TCP RemoteAddr only, so clients cannot spoof source_ip on a plain deployment. Documented in the guide.
  • Alphabetize the audit keys in ground-control/.env.example.

Commit 517dd7f

  • Address the follow-up cubic finding on parseIntEnv swallowing parse errors: added parseRequiredIntEnv which log.Fatalfs on a set-but-invalid integer (e.g. AUDIT_LOG_MAX_SIZE_MB=abc). The three audit call sites use the new helper; other callers of parseIntEnv are unchanged.

Not addressed (deliberately): the nit about omitting empty actor / source_ip fields. The schema in docs/guides/audit-logging.md documents these as always-present, and the unit tests assert on their presence, changing this would break the contract for SIEM consumers built against the published schema.

@coderabbitai review
@cubic-dev-ai re-review

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented May 17, 2026

Thanks for the reviews. Summary of what changed since 3ffd496:

Commit 2bdc3b8

  • Sanitize errors before emitting to the audit log on the satellite ZTR path so the token cannot leak through wrapped HTTP error messages (internal/state/registration_process.go, plus a unit test for the redaction).
  • Validate the AUDIT_LOG_MAX_* env values on GC: empty path or negative numbers now cause log.Fatalf at startup instead of being passed through to lumberjack.
    ...

@Aloui-Ikram I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

@Aloui-Ikram Thanks for the detailed summary! The changes in 2bdc3b8 and 517dd7f look solid:

  • Token sanitization with sanitizeAuditReason + unit tests is a clean approach to preventing accidental secret leakage through wrapped errors.
  • Startup validation via log.Fatalf for bad AUDIT_LOG_MAX_* values and the new parseRequiredIntEnv helper for set-but-invalid integers both give operators clear early feedback instead of silent misbehavior.
  • Opt-in forwarded-header trust (AUDIT_TRUST_FORWARDED_HEADERS=false by default) is the right default — safe deployments stay safe without extra configuration.

The reasoning on keeping actor and source_ip as always-present fields is sound — breaking a documented schema contract mid-PR would be worse than the minor verbosity of empty strings, especially for SIEM consumers that may already be building parsers against the published schema.

Triggering a fresh review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 25 files

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

Comment thread ground-control/internal/server/server.go Outdated
getEnvOrDefault returns the default whenever the env var is unset OR
set to empty, so the path == "" check that followed it could never
fire. An operator who wrote AUDIT_LOG_PATH= in their .env (typo, blank
value) silently got ./audit.log instead of the intended fatal error.

Switch to os.LookupEnv so we can tell the two cases apart: unset falls
back to ./audit.log, set-but-empty is a fatal misconfiguration.

Addresses cubic re-review on PR #448.

Signed-off-by: aloui ikram <ikram@container-registry.com>
@bupd bupd self-requested a review May 18, 2026 15:12
@Aloui-Ikram Aloui-Ikram changed the title feat: add audit logging for security events [WIP] feat: add audit logging for security events May 23, 2026
@Aloui-Ikram
Copy link
Copy Markdown
Member Author

@bupd @Vad1mo ,thank you for the feedback at the standup last week. Based on your input and the Harbor audit-logging gist, I reworked the AuditEvent schema into 17 fields.

Before I start writing code, I would like to get your opinion on the shape. You both know Harbor's conventions better than I do, so your feedback would help a lot here.

If the design works for you, the plan is:

  1. Land the schema in this PR
  2. Add syslog transport next
  3. OTel + CloudEvents envelope after that

The 17 fields and where each one comes from

Field Notes
event_id unique ID per event — kept, UUID v4
timestamp event time — kept, UTC RFC3339
severity lets SIEM prioritize without parsing event_type; maps directly to syslog PRI and OTel later — OWASP / syslog / OTel
actor who triggered the event — kept
actor_type distinguish user / robot / satellite / anonymous — OWASP via the gist
component which side emitted the event (satellite / ground-control) — was implicit in the filename before
satellite_id identifies which satellite the event came from
source_ip useful for investigations (brute-force, geo-anomaly). Would prefer to keep it since OWASP and Harbor both do, but open to dropping
user_agent helps identify the client (curl vs browser vs satellite binary) — OWASP via the gist
request_id correlate multiple events from one HTTP request — OWASP via the gist
operation verb: login, create, delete, update, register, deregister, password_change, auth — Harbor gist, keeps us consistent
resource_type noun: user, satellite, config, session, robot — Harbor gist
resource target instance ID — Harbor gist
outcome success / failure as a real field, not buried inside event_type — OWASP + Harbor + OTel
reason short sanitized failure code. Strawman enum: invalid_credentials | token_expired | token_invalid | forbidden | not_found | rate_limited — open to suggestions
event_type derived as {resource_type}.{operation}.{outcome} → e.g. user.delete.success. Breaking change vs the current user.created naming — flagging it explicitly
details free-form map. For config.changed: { "from": {...}, "to": {...} }

Mapping each field to OTel / syslog / CloudEvents

Using registered semantic conventions where they exist, custom attributes where they do not. No internal field renames will be needed when transports are added later.

Field OpenTelemetry syslog (RFC 5424) CloudEvents (v1.0.2)
event_id custom attribute (harbor.audit.event_id) STRUCTURED-DATA id (required core attribute)
timestamp Timestamp field on LogRecord TIMESTAMP header time (RFC 3339)
severity SeverityText + SeverityNumber fields on LogRecord PRI severity (critical→2, error→3, warning→4, info→6) severitytext + severitynumber (Severity extension)
actor user.id (Development stability) STRUCTURED-DATA authid (Auth Context extension)
actor_type custom attribute (harbor.audit.actor_type) STRUCTURED-DATA authtype (Auth Context extension; enum includes user, service_account, system, unauthenticated)
component service.name + service.namespace="harbor" (Stable) APP-NAME header (≤48 ASCII chars) part of source URI
satellite_id service.instance.id (Stable) STRUCTURED-DATA (custom SD-ID, name@<PEN> form per §7) part of source URI
source_ip client.address (Stable) — pair with network.peer.address when behind a proxy STRUCTURED-DATA data
user_agent user_agent.original (Stable) STRUCTURED-DATA data
request_id http.request.header.x-request-id (Stable template attribute, value is string[]) STRUCTURED-DATA data (or traceparent extension only if value is W3C TraceContext format)
operation custom attribute (harbor.audit.operation) STRUCTURED-DATA data
resource_type custom attribute (harbor.audit.resource_type) STRUCTURED-DATA data
resource custom attribute (harbor.audit.resource) STRUCTURED-DATA subject (optional core attribute, fits exactly)
outcome custom attribute (harbor.audit.outcome) STRUCTURED-DATA data
reason error.type (Stable; must be low-cardinality enum, not free-form text) STRUCTURED-DATA data
event_type EventName field on LogRecord shortened MSGID (≤32 ASCII chars, e.g. audit.user.delete); full string in STRUCTURED-DATA type (prefixed io.goharbor.satellite.audit.)
details LogRecord Attributes (mind cardinality) STRUCTURED-DATA (≥480 octets guaranteed, 2 KB recommended) data

Quick note: the CloudEvents Severity and Auth Context extensions are listed in the spec repo but are not part of the v1.0.2 core. i can use them, or put those fields in data instead ;


Could you let me know if this shape works, or if there are fields you would add, drop, or rename?

Thank you for your time.

@Vad1mo
Copy link
Copy Markdown
Contributor

Vad1mo commented May 25, 2026

it is not clear if all fields are meant to be mandatory?

@Aloui-Ikram
Copy link
Copy Markdown
Member Author

oh i should have made that clearer , sorry.

Out of the 17, only 8 are always there: event_id, timestamp,severity, component, event_type, operation,
resource_type, outcome. The code knows all of these at emit time so they show up on every event.

The other 9 are optional and omitted when we don't have a value for them. actor and actor_type drop off on unauthenticated events, source_ip / user_agent / request_id only show up on HTTP-originated events, satellite_id only when the event relates to a specific satellite, resource only when there's a concrete target, reason only on failures, and details only when there's extra structured context to attach.

The plan is to omit optional fields when unknown rather than emit empty strings, because OTel, CloudEvents and syslog all do it that way , keeps the internal shape aligned with the transports later on.

@bupd
Copy link
Copy Markdown
Member

bupd commented May 28, 2026

Review findings:

  1. High: audit logging can be enabled while silently dropping every event if the file cannot be opened.

    This applies to both internal/logger/audit.go and ground-control/internal/logger/audit.go. NewAuditLogger marks the logger enabled as soon as Enabled=true and FilePath is non-empty, but lumberjack opens the file lazily on the first write. If the directory does not exist or the process lacks write permission, zerolog.Event.Send() does not surface that write error to callers, so the service keeps running with audit.Enabled() == true while no audit file is produced.

    Realistic case: an operator sets AUDIT_LOG_PATH=/var/log/ground-control/audit.log or file_path=/var/log/harbor-satellite/audit.log, but the container image/user has no writable /var/log/... directory. The PR advertises audit logging as enabled, but auth/user/config events are lost with no startup failure.

    Please fail fast when audit logging is enabled and the destination is not writable, or return an error from logger construction and wire that into startup. A small startup probe that creates the parent directory if that is desired, opens the file with append/create permissions, and closes it would catch the common failure modes before accepting traffic.

  2. Medium: Satellite treats max_backups=0 and max_age_days=0 differently from Ground Control and the docs.

    In pkg/config/validate.go, validateAndEnforceAuditConfig defaults MaxBackups and MaxAgeDays when they are <= 0. lumberjack uses 0 as a meaningful value for these fields (0 means no limit for backup count/age), and the Ground Control validation now explicitly allows MAX_BACKUPS >= 0 and MAX_AGE_DAYS >= 0.

    Scenario: a Satellite operator configures max_backups: 0 or max_age_days: 0 to retain all rotated audit logs, but validation rewrites those to 7 and 30. That can unexpectedly delete audit history and makes Satellite behavior diverge from GC for the same audit contract.

    Please only default MaxBackups and MaxAgeDays when they are negative, while keeping MaxSizeMB <= 0 invalid/defaulted. The test at pkg/config/validate_test.go should cover the intentional zero case separately from negative values.

  3. Medium: Satellite audit config changes are not hot-reloaded.

    cmd/main.go constructs the audit logger once from cm.GetAuditConfig() and stores it in the request context/local audit variable before starting the watcher. Later, hot reload calls cm.ReloadConfig() and emits config.changed using that original logger, but it never recreates the logger when app_config.audit changes.

    Scenario: a Satellite is running with audit disabled and the config is hot-reloaded to enable audit logging. The config manager picks up the new audit settings, but the active logger remains the old no-op logger until process restart, and the enablement change itself is not recorded. The inverse is also possible: disabling audit or changing file_path keeps writing through the old logger.

    If app_config.audit is intended to be runtime-configurable like the rest of the config, the reload path should rebuild/swap the audit logger when audit fields change. If a restart is required, the guide should say that explicitly and the config change event should not imply live reconfiguration.

  4. Minor: the source_ip documentation still says X-Forwarded-For is always honored.

    docs/guides/audit-logging.md says source_ip “honors X-Forwarded-For first hop” in the field table. After the spoofing fix, that is only true when AUDIT_TRUST_FORWARDED_HEADERS=true; the default is RemoteAddr. The later configuration section is correct, but the table should be updated to avoid giving operators the wrong expectation.

  5. Nit: direct audit dependencies are still marked indirect.

    Both modules import github.com/google/uuid, github.com/rs/zerolog, and gopkg.in/natefinch/lumberjack.v2 directly from the new logger packages, but some of those entries remain in indirect require blocks/comments. This does not break the build, but running go mod tidy and keeping direct imports in direct require blocks would make the dependency intent clearer.

Verification run locally from the PR head worktree:

go test ./... -count=1
go test ./... -count=1  # from ground-control/

Audit logger (satellite + ground-control):
- NewAuditLogger returns (*AuditLogger, error) and checks the destination
  is writable up front, so an unwritable path fails fast at startup
  instead of looking enabled while silently dropping every event.
- Add in-place Reconfigure so the satellite rebuilds the logger on hot
  reload when audit settings change, instead of ignoring them until restart.

Satellite rotation config:
- MaxSizeMB/MaxBackups/MaxAgeDays become *int so an omitted field differs
  from an explicit 0. Omitted -> default (100/7/30), matching GC; explicit
  0 -> retain all; negative -> warn + default. Adds helpers to read these
  safely (defaulting when unset) and to compare two audit configs by value,
  so hot reload only rebuilds the logger on a real change.

Docs and deps:
- Fix the source_ip row in the audit-logging guide and document
  omit=default / 0=unlimited rotation.
- go mod tidy both modules.

Refs #238

Signed-off-by: aloui ikram <ikram@container-registry.com>
Comment thread internal/logger/audit.go Outdated
Comment thread internal/logger/audit.go Outdated
Comment thread internal/logger/audit.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ground-control/internal/logger/audit.go (1)

137-142: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same nanosecond-precision gap as internal/logger/audit.go.

zerolog's default TimeFieldFormat is time.RFC3339, so the emitted timestamp drops nanoseconds while the schema documents ns precision. Apply the same explicit-format fix here.

🛠️ Proposed fix
 	evt := a.log.Log().
 		Str("event_id", uuid.NewString()).
-		Time("timestamp", time.Now().UTC()).
+		Str("timestamp", time.Now().UTC().Format(time.RFC3339Nano)).
 		Str("event_type", string(eventType)).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ground-control/internal/logger/audit.go` around lines 137 - 142, The
timestamp field currently uses Time("timestamp", time.Now().UTC()) which loses
nanoseconds under zerolog's default format; replace that Time call on the evt
builder returned by a.log.Log() with an explicit string timestamp formatted with
time.RFC3339Nano (e.g., format time.Now().UTC() to RFC3339Nano and pass it via
Str("timestamp", ...)) so the emitted "timestamp" preserves nanosecond precision
consistent with the schema.
internal/logger/audit.go (1)

137-142: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix audit timestamp to preserve nanosecond precision

internal/logger/audit.go emits timestamp via zerolog’s .Time("timestamp", time.Now().UTC()), which uses zerolog’s default TimeFieldFormat (time.RFC3339, second precision) unless the global format is overridden. The repo has no TimeFieldFormat = ... override, so the output conflicts with docs/guides/audit-logging.md’s promise of “RFC 3339 with nanoseconds”. internal/logger/audit_test.go doesn’t fail because time.Parse(time.RFC3339Nano, ...) accepts second-precision RFC3339 timestamps too.

🛠️ Proposed fix
 	evt := a.log.Log().
 		Str("event_id", uuid.NewString()).
-		Time("timestamp", time.Now().UTC()).
+		Str("timestamp", time.Now().UTC().Format(time.RFC3339Nano)).
 		Str("event_type", string(eventType)).
 		Str("actor", actor).
 		Str("source_ip", sourceIP)

This avoids relying on any global zerolog.TimeFieldFormat configuration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/logger/audit.go` around lines 137 - 142, Replace the zerolog .Time
call so timestamps are emitted with RFC3339 nanosecond precision: instead of
using a.log.Log().Time("timestamp", time.Now().UTC()) in the evt builder, format
the time explicitly with time.RFC3339Nano (e.g., use
time.Now().UTC().Format(time.RFC3339Nano)) and set it via .Str("timestamp",
...); update any imports if necessary and keep the rest of the evt chain
(Str("event_id", ...), Str("event_type", ...), etc.) unchanged.
🧹 Nitpick comments (1)
ground-control/internal/logger/audit.go (1)

1-176: 🏗️ Heavy lift

Deduplicate the audit logger to avoid drift between root and ground-control

  • ground-control/internal/logger/audit.go is not a byte-for-byte copy of internal/logger/audit.go (it already differs by adding type contextKey string near auditLoggerKey), so future fixes risk landing in only one place.
  • Since ground-control depends on the root module and its import paths are under github.com/container-registry/harbor-satellite, consider importing the root internal/logger implementation from ground-control instead of maintaining a second copy (or keep duplication intentional, but treat sync as required).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ground-control/internal/logger/audit.go` around lines 1 - 176, This file
duplicates the root internal/logger implementation and should be removed in
favor of importing the canonical package; delete
ground-control/internal/logger/audit.go and update callers in ground-control to
import github.com/container-registry/harbor-satellite/internal/logger and use
the existing types and functions (AuditLogger, AuditConfig, NewAuditLogger,
(*AuditLogger).Reconfigure, (*AuditLogger).Log, (*AuditLogger).Enabled,
WithAuditLogger, AuditFromContext and the auditLoggerKey/contextKey handling) so
there is a single source of truth and no drift between root and ground-control.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@ground-control/internal/logger/audit.go`:
- Around line 137-142: The timestamp field currently uses Time("timestamp",
time.Now().UTC()) which loses nanoseconds under zerolog's default format;
replace that Time call on the evt builder returned by a.log.Log() with an
explicit string timestamp formatted with time.RFC3339Nano (e.g., format
time.Now().UTC() to RFC3339Nano and pass it via Str("timestamp", ...)) so the
emitted "timestamp" preserves nanosecond precision consistent with the schema.

In `@internal/logger/audit.go`:
- Around line 137-142: Replace the zerolog .Time call so timestamps are emitted
with RFC3339 nanosecond precision: instead of using
a.log.Log().Time("timestamp", time.Now().UTC()) in the evt builder, format the
time explicitly with time.RFC3339Nano (e.g., use
time.Now().UTC().Format(time.RFC3339Nano)) and set it via .Str("timestamp",
...); update any imports if necessary and keep the rest of the evt chain
(Str("event_id", ...), Str("event_type", ...), etc.) unchanged.

---

Nitpick comments:
In `@ground-control/internal/logger/audit.go`:
- Around line 1-176: This file duplicates the root internal/logger
implementation and should be removed in favor of importing the canonical
package; delete ground-control/internal/logger/audit.go and update callers in
ground-control to import
github.com/container-registry/harbor-satellite/internal/logger and use the
existing types and functions (AuditLogger, AuditConfig, NewAuditLogger,
(*AuditLogger).Reconfigure, (*AuditLogger).Log, (*AuditLogger).Enabled,
WithAuditLogger, AuditFromContext and the auditLoggerKey/contextKey handling) so
there is a single source of truth and no drift between root and ground-control.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6c007b15-bb83-4021-b27a-6f70e58bd999

📥 Commits

Reviewing files that changed from the base of the PR and between 2bdc3b8 and 5862852.

⛔ Files ignored due to path filters (1)
  • ground-control/go.sum is excluded by !**/*.sum
📒 Files selected for processing (12)
  • cmd/main.go
  • docs/guides/audit-logging.md
  • go.mod
  • ground-control/go.mod
  • ground-control/internal/logger/audit.go
  • ground-control/internal/logger/audit_test.go
  • ground-control/internal/server/server.go
  • internal/logger/audit.go
  • internal/logger/audit_test.go
  • pkg/config/config.go
  • pkg/config/validate.go
  • pkg/config/validate_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/config/validate.go
  • docs/guides/audit-logging.md
  • ground-control/internal/server/server.go

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 13 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread cmd/main.go Outdated
Comment thread ground-control/internal/logger/audit.go
Comment thread ground-control/internal/logger/audit.go Outdated
aloui ikram and others added 2 commits May 31, 2026 23:06
- Record config.changed before disabling the audit logger on hot reload,
  so turning audit off is itself audited (it was emitted through the new
  no-op logger and lost). Reload handling moves into reconfigureAuditOnReload.
- Detect audit-section changes in ConfigManager.detectChanges, so an
  audit-only hot reload (enable/disable or path change) actually triggers a
  reconfigure instead of being ignored unless a zot/log/interval change
  happened to ride along.
- Create the audit log directory as 0o750 instead of 0o755; audit logs hold
  usernames/IPs/failure reasons and should not be world-listable.
- Format the audit timestamp explicitly as RFC3339Nano so precision is
  deterministic and matches the documented contract, instead of depending on
  zerolog's global TimeFieldFormat.
- Split audit rotation defaulting into enforceAuditRotation to keep
  validateAndEnforceAuditConfig under the cyclomatic-complexity limit.

Refs #238

Signed-off-by: aloui ikram <ikram@container-registry.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Jun 2, 2026

Deploying harbor-satellite with  Cloudflare Pages  Cloudflare Pages

Latest commit: 09c7b87
Status: ✅  Deploy successful!
Preview URL: https://e2242505.harbor-satellite.pages.dev
Branch Preview URL: https://feat-audit-logging-238.harbor-satellite.pages.dev

View logs

@gitar-bot
Copy link
Copy Markdown

gitar-bot Bot commented Jun 2, 2026

Code Review ✅ Approved 3 resolved / 3 findings

Implements structured audit logging across Ground Control and Satellite for security-relevant events, including configurable rotation and SIEM integration. AuditFromContext performs an unnecessary allocation on every context miss, which should be optimized to avoid overhead.

✅ 3 resolved
Security: Audit log directory created with world-readable permissions (0755)

📄 internal/logger/audit.go:116 📄 ground-control/internal/logger/audit.go:116
The ensureWritable function creates the audit log's parent directory with os.MkdirAll(dir, 0o755), making it world-readable. Security audit logs typically contain sensitive information (usernames, IPs, failure reasons) and their directory should be restricted to the service owner. A permission of 0o750 or 0o700 would prevent other local users from listing or accessing rotated audit log files.

Quality: Timestamp lacks nanosecond precision despite docs claiming it

📄 internal/logger/audit.go:137-139 📄 ground-control/internal/logger/audit.go:137-139 📄 docs/guides/audit-logging.md:31
The documentation (docs/guides/audit-logging.md line 31) states timestamps use "UTC, RFC 3339 with nanoseconds", and the PR description says "nanosecond precision". However, zerolog's default TimeFieldFormat is time.RFC3339 (second precision only), and no override is set on the audit logger. The E2E output from Ground Control confirms second-only precision (e.g. 2026-05-17T15:02:01Z). Either set zerolog.TimeFieldFormat = time.RFC3339Nano on the audit logger's underlying writer, or update the documentation to say "RFC 3339" without the nanosecond claim.

Performance: AuditFromContext allocates a new AuditLogger on every miss

📄 internal/logger/audit.go:168-174 📄 ground-control/internal/logger/audit.go:170-176
AuditFromContext creates a new zerolog.Logger and AuditLogger struct every time it's called without a logger in the context. While the current call sites are infrequent (satellite ZTR registration), this pattern is fragile — a future hot-path caller would cause unnecessary allocations. A package-level no-op singleton avoids repeated allocation.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 2 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@bupd
Copy link
Copy Markdown
Member

bupd commented Jun 2, 2026

Follow-up review, focused on the audit event model and transport compatibility:

  1. High: the current audit event schema is still not syslog / OpenTelemetry ready as a canonical model.

    The implementation still emits the minimal shape from internal/logger/audit.go / ground-control/internal/logger/audit.go: event_id, timestamp, event_type, actor, source_ip, and optional details. That is enough for a local JSON log, but it leaves several fields that syslog and OTel need as first-class values either missing or only inferable by parsing strings:

    • severity is absent, so syslog PRI severity and OTel SeverityText / SeverityNumber would need a transport-specific lookup table over event_type.
    • component is absent from the event, so the record itself does not say whether it came from Satellite or Ground Control. A shipper can infer this from file path today, but OTel/syslog transports should not depend on deployment-specific filenames.
    • operation, resource_type, resource, and outcome are absent. The current event_type values are not consistently shaped as {resource}.{operation}.{outcome} (user.created, config.changed, satellite.registered, user.password_changed), so transports cannot reliably derive low-cardinality OTel attributes or syslog structured data without special cases.
    • reason is still nested in free-form details instead of being a top-level low-cardinality failure code. For OTel this maps much more naturally to error.type; burying it in details makes alerting and aggregation weaker.
    • HTTP context such as request_id and user_agent is not captured, which limits incident correlation and makes later CloudEvents/OTel wrapping less useful.

    If this PR is meant to establish the long-term audit contract, I would not land the minimal schema and then retrofit the model later. Please promote the transport-relevant fields into the core audit event struct now, even if some are optional. A good minimum compatible shape would include: event_id, timestamp, severity, component, event_type, operation, resource_type, outcome, optional actor, actor_type, source_ip, user_agent, request_id, resource, reason, satellite_id, and details.

    With that shape, syslog can map severity to PRI, component to APP-NAME, event_type to MSGID/structured data, and the rest to structured data. OTel can map timestamp/severity directly, use service.name / service.namespace for component, and attach operation/resource/outcome/reason as stable low-cardinality attributes without parsing event_type.

  2. Medium: the docs currently document the minimal JSON shape, so consumers would build against a contract we already know is incomplete.

    docs/guides/audit-logging.md still presents only the minimal JSON example and field table. If the intended direction is the richer syslog/OTel-compatible model discussed in the PR thread, the guide should be updated in the same PR so SIEM/log-pipeline consumers do not lock onto the old shape.

The implementation follow-ups from the previous review look substantially improved: startup now probes audit log writability, Satellite audit settings are detected during hot reload, zero retention values are preserved, source_ip docs were corrected, and direct dependencies were cleaned up.

Verification run locally at PR head 09c7b879beb38d1476fc8ed76d0a3f62da05fd1d:

go test ./... -count=1
(cd ground-control && go test ./... -count=1)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compliance Compliance and auditing component:ground-control Ground Control component component:satellite Satellite component documentation Improvements or additions to documentation enhancement New feature or request golang security Security related

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

Audit Logging for Security Events

3 participants