Skip to content

Commit f01fc89

Browse files
authored
extend audit trail for Hermes (#75)
* bump go-bits to support durability for dataplane.audit queue On-behalf-of: SAP <filipp.akinfiev@clyso.com> Signed-off-by: Filipp Akinfiev <filipp.akinfiev@clyso.com> * feat(opslog): extend audit trail for Hermes (tenant/read/loop filters) On-behalf-of: SAP <filipp.akinfiev@clyso.com> Signed-off-by: Filipp Akinfiev <filipp.akinfiev@clyso.com> --------- Signed-off-by: Filipp Akinfiev <filipp.akinfiev@clyso.com>
1 parent e99d4e6 commit f01fc89

11 files changed

Lines changed: 450 additions & 18 deletions

File tree

docs/ops-log.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,10 +365,17 @@ Each S3 operation produces a CADF event with:
365365
| Variable | Description | Default |
366366
|----------|-------------|---------|
367367
| `AUDIT_ENABLED` | Publish to RabbitMQ | `false` |
368-
| `AUDIT_RABBITMQ_URL` | Connection URL (`amqp://user:pass@host:port`) | |
369-
| `AUDIT_QUEUE_NAME` | Queue name | `keystone.notifications.info` |
368+
| `AUDIT_RABBITMQ_URL` | Connection URL (`amqp://host:port`) | |
369+
| `AUDIT_RABBITMQ_USERNAME` | Username; overrides URL userinfo (e.g. from Vault) | |
370+
| `AUDIT_RABBITMQ_PASSWORD` | Password; overrides URL userinfo | |
371+
| `AUDIT_QUEUE_NAME` | Queue name (`dataplane.audit` → durable queue) | `keystone.notifications.info` |
370372
| `AUDIT_QUEUE_SIZE` | Internal event buffer size | `20` |
371373
| `AUDIT_DEBUG` | Log published events | `false` |
374+
| `AUDIT_REQUIRE_TENANT` | Drop events lacking `project_id`/`domain_id` (counted in `prysm_audit_events_dropped_total`) | `true` |
375+
| `AUDIT_OBSERVER_NAME` | CADF observer name (storage service) | `radosgw` |
376+
| `AUDIT_REGION` | Static region stamped on events (empty = off) | |
377+
| `AUDIT_INCLUDE_READS` | Audit reads (get/head/list) too; false = mutations-only | `true` |
378+
| `AUDIT_SKIP_BUCKETS` | Buckets excluded from audit (comma-list, loop prevention) | `hermes` |
372379

373380
### Metrics tracking
374381

ops-log-k8s-mutating-wh/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ stringData:
197197
| `AUDIT_QUEUE_NAME` | Target queue (see durability note below) | `keystone.notifications.info` |
198198
| `AUDIT_QUEUE_SIZE` | Internal event buffer size | `20` |
199199
| `AUDIT_DEBUG` | Log every published event (verbose) | `false` |
200+
| `AUDIT_REQUIRE_TENANT` | Drop events lacking a project_id/domain_id (counted) | `true` |
201+
| `AUDIT_OBSERVER_NAME` | CADF observer name (storage service) | `radosgw` |
202+
| `AUDIT_REGION` | Static region stamped on events (empty = off) | _empty_ |
203+
| `AUDIT_INCLUDE_READS` | Audit reads (get/head/list) too; false = mutations-only | `true` |
204+
| `AUDIT_SKIP_BUCKETS` | Buckets excluded from audit (comma-list, loop prevention) | `hermes` |
205+
206+
> These are non-sensitive — put them in the ConfigMap (`sidecarEnvConfig.config`),
207+
> not the Secret. Only the RabbitMQ credentials belong in the Secret.
200208

201209
> If `AUDIT_ENABLED=true` but `AUDIT_RABBITMQ_URL` is empty, the sidecar logs a
202210
> warning and falls back to a no-op auditor — log processing is never blocked.

pkg/commands/producer_ops_log.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ var (
3838
opsAuditQueueName string
3939
opsAuditInternalQueueSize int
4040
opsAuditDebug bool
41+
opsAuditRequireTenant bool
42+
opsAuditRegion string
43+
opsAuditObserverName string
44+
opsAuditIncludeReads bool
45+
opsAuditSkipBuckets string
4146

4247
// Shortcut config
4348
opsTrackEverything bool
@@ -231,6 +236,11 @@ Following this configuration change, the RadosGW will log operations to the file
231236
QueueName: opsAuditQueueName,
232237
InternalQueueSize: opsAuditInternalQueueSize,
233238
Debug: opsAuditDebug,
239+
RequireTenant: opsAuditRequireTenant,
240+
Region: opsAuditRegion,
241+
ObserverName: opsAuditObserverName,
242+
IncludeReads: opsAuditIncludeReads,
243+
SkipBuckets: opsAuditSkipBuckets,
234244
},
235245
}
236246

@@ -654,6 +664,11 @@ func mergeOpsLogConfigWithEnv(cfg opslog.OpsLogConfig) opslog.OpsLogConfig {
654664
cfg.AuditSink.RabbitMQUsername = getEnv("AUDIT_RABBITMQ_USERNAME", cfg.AuditSink.RabbitMQUsername)
655665
cfg.AuditSink.RabbitMQPassword = getEnv("AUDIT_RABBITMQ_PASSWORD", cfg.AuditSink.RabbitMQPassword)
656666
cfg.AuditSink.QueueName = getEnv("AUDIT_QUEUE_NAME", cfg.AuditSink.QueueName)
667+
cfg.AuditSink.RequireTenant = getEnvBool("AUDIT_REQUIRE_TENANT", cfg.AuditSink.RequireTenant)
668+
cfg.AuditSink.Region = getEnv("AUDIT_REGION", cfg.AuditSink.Region)
669+
cfg.AuditSink.ObserverName = getEnv("AUDIT_OBSERVER_NAME", cfg.AuditSink.ObserverName)
670+
cfg.AuditSink.IncludeReads = getEnvBool("AUDIT_INCLUDE_READS", cfg.AuditSink.IncludeReads)
671+
cfg.AuditSink.SkipBuckets = getEnv("AUDIT_SKIP_BUCKETS", cfg.AuditSink.SkipBuckets)
657672
cfg.AuditSink.InternalQueueSize = getEnvInt("AUDIT_QUEUE_SIZE", cfg.AuditSink.InternalQueueSize)
658673
cfg.AuditSink.Debug = getEnvBool("AUDIT_DEBUG", cfg.AuditSink.Debug)
659674

@@ -684,6 +699,11 @@ func init() {
684699
opsLogCmd.Flags().StringVar(&opsAuditQueueName, "audit-queue-name", "keystone.notifications.info", "RabbitMQ queue name for audit events")
685700
opsLogCmd.Flags().IntVar(&opsAuditInternalQueueSize, "audit-queue-size", 20, "Internal queue size for audit events")
686701
opsLogCmd.Flags().BoolVar(&opsAuditDebug, "audit-debug", false, "Log published audit events for debugging")
702+
opsLogCmd.Flags().BoolVar(&opsAuditRequireTenant, "audit-require-tenant", true, "Drop audit events that have neither a project_id nor a domain_id (the audit consumer rejects them)")
703+
opsLogCmd.Flags().StringVar(&opsAuditRegion, "audit-region", "", "Static region stamped onto each audit event (the ops log has none); empty = not stamped")
704+
opsLogCmd.Flags().StringVar(&opsAuditObserverName, "audit-observer-name", "radosgw", "CADF observer name identifying the storage service in audit events (e.g. radosgw/ceph/swift)")
705+
opsLogCmd.Flags().BoolVar(&opsAuditIncludeReads, "audit-include-reads", true, "Audit read operations (get/head/list); default true for object-storage data-access auditing. Set false for mutations-only")
706+
opsLogCmd.Flags().StringVar(&opsAuditSkipBuckets, "audit-skip-buckets", "hermes", "Comma-separated, case-insensitive bucket names excluded from audit (loop prevention for the Hermes audit bucket)")
687707

688708
// Shortcut flag
689709
opsLogCmd.Flags().BoolVar(&opsTrackEverything, "track-everything", false, "Enable detailed tracking for all metric types (efficient mode)")

pkg/commands/producer_ops_log_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func TestMergeOpsLogConfigWithEnv_AuditSink(t *testing.T) {
2323
QueueName: "keystone.notifications.info",
2424
InternalQueueSize: 20,
2525
Debug: false,
26+
RequireTenant: true,
2627
},
2728
}
2829

@@ -34,6 +35,11 @@ func TestMergeOpsLogConfigWithEnv_AuditSink(t *testing.T) {
3435
t.Setenv("AUDIT_QUEUE_NAME", "custom.audit.queue")
3536
t.Setenv("AUDIT_QUEUE_SIZE", "100")
3637
t.Setenv("AUDIT_DEBUG", "true")
38+
t.Setenv("AUDIT_REQUIRE_TENANT", "false")
39+
t.Setenv("AUDIT_REGION", "qa-de-1")
40+
t.Setenv("AUDIT_OBSERVER_NAME", "ceph")
41+
t.Setenv("AUDIT_INCLUDE_READS", "true")
42+
t.Setenv("AUDIT_SKIP_BUCKETS", "hermes,_default")
3743

3844
cfg := mergeOpsLogConfigWithEnv(base)
3945

@@ -44,6 +50,11 @@ func TestMergeOpsLogConfigWithEnv_AuditSink(t *testing.T) {
4450
assert.Equal(t, "custom.audit.queue", cfg.AuditSink.QueueName)
4551
assert.Equal(t, 100, cfg.AuditSink.InternalQueueSize)
4652
assert.True(t, cfg.AuditSink.Debug)
53+
assert.False(t, cfg.AuditSink.RequireTenant)
54+
assert.Equal(t, "qa-de-1", cfg.AuditSink.Region)
55+
assert.Equal(t, "ceph", cfg.AuditSink.ObserverName)
56+
assert.True(t, cfg.AuditSink.IncludeReads)
57+
assert.Equal(t, "hermes,_default", cfg.AuditSink.SkipBuckets)
4758
})
4859

4960
t.Run("unset env vars preserve flag defaults", func(t *testing.T) {
@@ -56,5 +67,10 @@ func TestMergeOpsLogConfigWithEnv_AuditSink(t *testing.T) {
5667
assert.Equal(t, "keystone.notifications.info", cfg.AuditSink.QueueName)
5768
assert.Equal(t, 20, cfg.AuditSink.InternalQueueSize)
5869
assert.False(t, cfg.AuditSink.Debug)
70+
assert.True(t, cfg.AuditSink.RequireTenant)
71+
assert.Equal(t, "", cfg.AuditSink.Region)
72+
assert.Equal(t, "", cfg.AuditSink.ObserverName)
73+
assert.False(t, cfg.AuditSink.IncludeReads)
74+
assert.Equal(t, "", cfg.AuditSink.SkipBuckets)
5975
})
6076
}

pkg/producers/opslog/README.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,29 @@ prysm local-producer ops-log [flags]
7373
connection, client, server).
7474
- `--audit-enabled` - Enable RabbitMQ audit trail publishing.
7575
- `--audit-rabbitmq-url` - RabbitMQ connection URL (e.g.,
76-
`amqp://user:pass@host:port`).
76+
`amqp://host:port`; credentials may be embedded or supplied separately).
77+
- `--audit-rabbitmq-username` - RabbitMQ username; overrides any userinfo in
78+
the URL (e.g. sourced from a Vault entry).
79+
- `--audit-rabbitmq-password` - RabbitMQ password; overrides any userinfo in
80+
the URL.
7781
- `--audit-queue-name` - RabbitMQ queue name for audit events (default:
78-
`keystone.notifications.info`).
82+
`keystone.notifications.info`). Set to `dataplane.audit` to get a durable
83+
queue (see note below).
7984
- `--audit-queue-size` - Internal audit event queue size (default: 20).
8085
- `--audit-debug` - Enable debug logging for published audit events.
86+
- `--audit-require-tenant` - Drop audit events that have neither a `project_id`
87+
nor a `domain_id` (default: true; the customer audit consumer rejects them).
88+
- `--audit-observer-name` - CADF observer name identifying the storage service
89+
in audit events (default: `radosgw`).
90+
- `--audit-region` - Static region stamped onto each audit event (the ops log
91+
has none; default: empty = not stamped).
92+
- `--audit-include-reads` - Audit read operations (get/head/list) in addition
93+
to mutations (default: true, for object-storage data-access auditing). Set
94+
false for mutations-only.
95+
- `--audit-skip-buckets` - Comma-separated, case-insensitive bucket names
96+
excluded from audit (default: `hermes`). Breaks the Hermes loop: Hermes writes
97+
audit events into this bucket, and auditing those writes would re-trigger
98+
events. Empty disables the filter.
8199

82100
### Latency Tracking Examples:
83101

@@ -146,9 +164,16 @@ prysm local-producer ops-log \
146164
| `TRACK_BUCKET_SLO` | Enable low-cardinality bucket GET/LIST SLI metrics. |
147165
| `AUDIT_ENABLED` | Enable RabbitMQ audit trail publishing. |
148166
| `AUDIT_RABBITMQ_URL` | RabbitMQ connection URL. |
149-
| `AUDIT_QUEUE_NAME` | RabbitMQ queue name for audit events. |
167+
| `AUDIT_RABBITMQ_USERNAME` | RabbitMQ username; overrides URL userinfo. |
168+
| `AUDIT_RABBITMQ_PASSWORD` | RabbitMQ password; overrides URL userinfo. |
169+
| `AUDIT_QUEUE_NAME` | RabbitMQ queue name (`dataplane.audit` = durable). |
150170
| `AUDIT_QUEUE_SIZE` | Internal audit event queue size. |
151171
| `AUDIT_DEBUG` | Enable debug logging for audit events. |
172+
| `AUDIT_REQUIRE_TENANT` | Drop events without a project_id/domain_id (default true). |
173+
| `AUDIT_OBSERVER_NAME` | CADF observer (storage service) name (default radosgw). |
174+
| `AUDIT_REGION` | Static region stamped on events (empty = off). |
175+
| `AUDIT_INCLUDE_READS` | Audit reads (get/head/list) too (default true). |
176+
| `AUDIT_SKIP_BUCKETS` | Buckets excluded from audit, comma-list (default `hermes`). |
152177

153178
#### Request Tracking Environment Variables:
154179

@@ -410,6 +435,49 @@ These events are consumed by Hermes and other audit processing systems.
410435
(development/testing)
411436
- **Buffered Channel**: 20-event internal queue with automatic retry on
412437
failures
438+
- **Tenant Enforcement**: With `AUDIT_REQUIRE_TENANT=true` (default), events
439+
without a `project_id` or `domain_id` are dropped before publishing — the
440+
customer audit consumer (Hermes) requires a tenant. Dropped events are
441+
counted in `prysm_audit_events_dropped_total{reason="no_tenant"}`, not
442+
silently discarded.
443+
444+
### Event filtering
445+
446+
Audit emission is gated before publishing. Each drop is counted (not silent) in
447+
`prysm_audit_events_dropped_total{reason}`:
448+
449+
- **`skip_bucket`** — operations on a bucket listed in `AUDIT_SKIP_BUCKETS`
450+
(default `hermes`) are excluded. This breaks the Hermes loop: Hermes writes
451+
audit events into a per-customer (WORM) bucket, and auditing those writes
452+
would re-trigger events indefinitely. Bucket-name matching is correct here
453+
because the audit bucket lives inside each customer's project — only Hermes
454+
can write to it (WORM), so nothing legitimate is lost.
455+
- **`no_tenant`** — events without a `project_id`/`domain_id` (see Tenant
456+
Enforcement above).
457+
- **`read`** — read operations (get/head/list) when `AUDIT_INCLUDE_READS=false`.
458+
Reads are audited by default (object-storage data-access events); set the flag
459+
false for a mutations-only trail.
460+
461+
### Durable queue
462+
463+
The underlying `go-bits/audittools` library declares the RabbitMQ queue
464+
**durable** only when `AUDIT_QUEUE_NAME` is exactly `dataplane.audit`; any other
465+
name is transient. The dataplane audit log-router consumes `dataplane.audit` and
466+
requires a durable queue, so set `AUDIT_QUEUE_NAME=dataplane.audit` for it to
467+
connect. Note: the queue survives a broker restart, but messages are still
468+
published transient. If the queue already exists with a different durability
469+
flag, delete it first — RabbitMQ rejects a redeclare with `406
470+
PRECONDITION_FAILED`.
471+
472+
### Observer and region
473+
474+
- **Observer**: events identify the storage service via
475+
`observer = { typeURI: "service/storage", name: <AUDIT_OBSERVER_NAME> }`
476+
(default name `radosgw`).
477+
- **Region**: the ops log carries no region, so it can be supplied statically
478+
via `AUDIT_REGION` and is stamped onto the target as a `region` attachment.
479+
Leave empty to omit. (Placement may change pending audit-consumer
480+
confirmation.)
413481

414482
### CADF Event Structure
415483

pkg/producers/opslog/audit.go

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ import (
1919
"github.com/sapcc/go-bits/audittools"
2020
)
2121

22+
// buildObserver constructs the CADF observer that identifies the storage
23+
// service reporting the event (the service, not the resource or the tool).
24+
// The name is configurable and defaults to "radosgw".
25+
func buildObserver(cfg AuditSinkConfig) audittools.Observer {
26+
name := cfg.ObserverName
27+
if name == "" {
28+
name = "radosgw"
29+
}
30+
return audittools.Observer{
31+
TypeURI: "service/storage",
32+
Name: name,
33+
ID: audittools.GenerateUUID(),
34+
}
35+
}
36+
2237
// InitAuditor initializes the audit trail based on configuration.
2338
// Returns a NullAuditor if audit sink is not configured or disabled.
2439
func InitAuditor(ctx context.Context, cfg AuditSinkConfig, registry prometheus.Registerer) audittools.Auditor {
@@ -46,12 +61,8 @@ func InitAuditor(ctx context.Context, cfg AuditSinkConfig, registry prometheus.R
4661
auditor, err := audittools.NewAuditor(ctx, audittools.AuditorOpts{
4762
ConnectionURL: connectionURL,
4863
QueueName: cfg.QueueName,
49-
Observer: audittools.Observer{
50-
TypeURI: "service/storage/object",
51-
Name: "prysm-ops-log",
52-
ID: audittools.GenerateUUID(),
53-
},
54-
Registry: registry,
64+
Observer: buildObserver(cfg),
65+
Registry: registry,
5566
})
5667

5768
if err != nil {
@@ -97,8 +108,37 @@ func buildRabbitMQConnectionURL(rawURL, username, password string) (string, erro
97108
return u.String(), nil
98109
}
99110

100-
// ToAuditEvent converts an S3OperationLog to an audittools.Event.
101-
func (opLog *S3OperationLog) ToAuditEvent() (audittools.Event, error) {
111+
// regionTarget decorates a Target with a static region attachment. Region is
112+
// a per-cluster deployment fact (the ops log has none), so it is supplied via
113+
// config rather than derived per request. Implemented as a decorator so the
114+
// placement can be changed easily if the audit consumer expects it elsewhere.
115+
type regionTarget struct {
116+
inner audittools.Target
117+
region string
118+
}
119+
120+
func (t regionTarget) Render() cadf.Resource {
121+
resource := t.inner.Render()
122+
resource.Attachments = append(resource.Attachments, cadf.Attachment{
123+
Name: "region",
124+
TypeURI: "xs:string",
125+
Content: t.region,
126+
})
127+
return resource
128+
}
129+
130+
// withRegion wraps a Target so its rendered resource carries the region. An
131+
// empty region returns the target unchanged (no attachment added).
132+
func withRegion(target audittools.Target, region string) audittools.Target {
133+
if region == "" {
134+
return target
135+
}
136+
return regionTarget{inner: target, region: region}
137+
}
138+
139+
// ToAuditEvent converts an S3OperationLog to an audittools.Event. The region is
140+
// a static per-cluster value stamped onto the target (empty = not stamped).
141+
func (opLog *S3OperationLog) ToAuditEvent(region string) (audittools.Event, error) {
102142
// Parse timestamp
103143
eventTime, err := time.Parse("2006-01-02T15:04:05.999999Z", opLog.Time)
104144
if err != nil {
@@ -126,7 +166,7 @@ func (opLog *S3OperationLog) ToAuditEvent() (audittools.Event, error) {
126166
User: buildUserInfo(opLog),
127167
ReasonCode: reasonCode,
128168
Action: mapOperationToAction(opLog.Operation),
129-
Target: buildTarget(opLog),
169+
Target: withRegion(buildTarget(opLog), region),
130170
}, nil
131171
}
132172

@@ -170,6 +210,34 @@ func buildHTTPRequest(opLog *S3OperationLog) (*http.Request, error) {
170210
return req, nil
171211
}
172212

213+
// isSkippedBucket reports whether operations on the given bucket must be
214+
// excluded from audit. This breaks the Hermes loop: Hermes writes audit events
215+
// into a per-customer WORM bucket, and those writes would otherwise generate
216+
// new audit events. Matching is case-insensitive over a comma-separated list;
217+
// an empty list disables the filter.
218+
func isSkippedBucket(bucket, skipBuckets string) bool {
219+
if bucket == "" || skipBuckets == "" {
220+
return false
221+
}
222+
b := strings.ToLower(strings.TrimSpace(bucket))
223+
for _, name := range strings.Split(skipBuckets, ",") {
224+
if b == strings.ToLower(strings.TrimSpace(name)) {
225+
return true
226+
}
227+
}
228+
return false
229+
}
230+
231+
// isReadOperation reports whether an RGW operation is a read (get/head/list).
232+
// Read classification is by operation name and is independent of the CADF
233+
// action mapping, so it is robust regardless of how actions are finalized.
234+
// Reads are excluded from the customer audit trail by default (mutations-only).
235+
func isReadOperation(operation string) bool {
236+
return strings.HasPrefix(operation, "get_") ||
237+
strings.HasPrefix(operation, "head_") ||
238+
strings.HasPrefix(operation, "list_")
239+
}
240+
173241
// mapOperationToAction converts RadosGW operation names to CADF actions.
174242
func mapOperationToAction(operation string) cadf.Action {
175243
switch operation {
@@ -206,14 +274,38 @@ func buildTarget(opLog *S3OperationLog) audittools.Target {
206274
Bucket: opLog.Bucket,
207275
}
208276
} else {
209-
// Account-level operation (e.g., list_buckets)
210-
projectID := extractProjectID(opLog.User)
277+
// Account-level operation (e.g., list_buckets). Prefer the Keystone
278+
// project ID (same source as the initiator) so target and initiator
279+
// agree; fall back to the parsed user when no Keystone scope exists.
280+
projectID, _ := resolveTenant(opLog)
211281
return &AccountTarget{
212282
ProjectID: projectID,
213283
}
214284
}
215285
}
216286

287+
// resolveTenant returns the project ID and domain ID that would populate the
288+
// CADF initiator for this entry, using the same precedence as buildUserInfo:
289+
// the Keystone scope when present, otherwise the project parsed from the user.
290+
func resolveTenant(opLog *S3OperationLog) (projectID, domainID string) {
291+
if opLog.KeystoneScope != nil {
292+
return opLog.KeystoneScope.Project.ID, opLog.KeystoneScope.Project.Domain.ID
293+
}
294+
return extractProjectID(opLog.User), ""
295+
}
296+
297+
// hasUsableTenant reports whether the entry yields a project_id or domain_id
298+
// for the CADF initiator. The audit consumer rejects events that carry
299+
// neither, so AUDIT_REQUIRE_TENANT uses this to drop them before publishing.
300+
// The anonymous sentinel is treated as no tenant.
301+
func hasUsableTenant(opLog *S3OperationLog) bool {
302+
projectID, domainID := resolveTenant(opLog)
303+
if domainID != "" {
304+
return true
305+
}
306+
return projectID != "" && projectID != "anonymous"
307+
}
308+
217309
// extractProjectID extracts the project ID from the user field.
218310
// User format is typically: "projectID$projectID" or just "projectID"
219311
func extractProjectID(user string) string {

0 commit comments

Comments
 (0)