Skip to content

Commit a8580d3

Browse files
authored
Merge pull request #398 from devtron-labs/feat-audit-logs-v2
feat: audit logs
2 parents 7b78e22 + de04baa commit a8580d3

8 files changed

Lines changed: 693 additions & 0 deletions

File tree

common-lib/pubsub-lib/JetStreamUtil.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ const (
139139
STORAGE_VELERO_POST_INSTALLATION_TOPIC string = "STORAGE_VELERO_POST_INSTALLATION_TOPIC"
140140
STORAGE_VELERO_POST_INSTALLATION_GROUP string = "STORAGE_VELERO_POST_INSTALLATION_GROUP"
141141
STORAGE_VELERO_POST_INSTALLATION_DURABLE string = "STORAGE_VELERO_POST_INSTALLATION_DURABLE"
142+
AUDIT_LOG_TOPIC string = "AUDIT_LOG_TOPIC"
143+
AUDIT_LOG_GROUP string = "AUDIT_LOG_GROUP"
144+
AUDIT_LOG_DURABLE string = "AUDIT_LOG_DURABLE"
142145
)
143146

144147
type NatsTopic struct {
@@ -199,6 +202,7 @@ var natsTopicMapping = map[string]NatsTopic{
199202
STORAGE_MODULE_TOPIC: {topicName: STORAGE_MODULE_TOPIC, streamName: ORCHESTRATOR_STREAM, queueName: STORAGE_MODULE_GROUP, consumerName: STORAGE_MODULE_DURABLE},
200203
STORAGE_VELERO_INSTALL_TOPIC: {topicName: STORAGE_VELERO_INSTALL_TOPIC, streamName: ORCHESTRATOR_STREAM, queueName: STORAGE_VELERO_INSTALL_GROUP, consumerName: STORAGE_VELERO_INSTALL_DURABLE},
201204
STORAGE_VELERO_POST_INSTALLATION_TOPIC: {topicName: STORAGE_VELERO_POST_INSTALLATION_TOPIC, streamName: ORCHESTRATOR_STREAM, queueName: STORAGE_VELERO_POST_INSTALLATION_GROUP, consumerName: STORAGE_VELERO_POST_INSTALLATION_DURABLE},
205+
AUDIT_LOG_TOPIC: {topicName: AUDIT_LOG_TOPIC, streamName: ORCHESTRATOR_STREAM, queueName: AUDIT_LOG_GROUP, consumerName: AUDIT_LOG_DURABLE},
202206
}
203207

204208
var NatsStreamWiseConfigMapping = map[string]NatsStreamConfig{
@@ -246,6 +250,7 @@ var NatsConsumerWiseConfigMapping = map[string]NatsConsumerConfig{
246250
STORAGE_MODULE_DURABLE: {},
247251
STORAGE_VELERO_INSTALL_DURABLE: {},
248252
STORAGE_VELERO_POST_INSTALLATION_DURABLE: {},
253+
AUDIT_LOG_DURABLE: {},
249254
}
250255

251256
// getConsumerConfigMap will fetch the consumer wise config from the json string
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2024. Devtron Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package audit
18+
19+
import (
20+
"encoding/json"
21+
"time"
22+
)
23+
24+
// TimeFormat is the wire format for AuditLogEvent.Time (ISO-8601, numeric zone).
25+
const TimeFormat = "2006-01-02T15:04:05-0700"
26+
27+
// RouteName builds the MODULE:RESOURCE:ACTION route name read back by the
28+
// orchestrator's audit log service. Using this helper keeps route tags
29+
// consistent with the typed Module/Resource/Action constants instead of
30+
// hand-written strings.
31+
func RouteName(module AuditModule, resource AuditResource, action AuditAction) string {
32+
return string(module) + ":" + string(resource) + ":" + string(action)
33+
}
34+
35+
// ResourceRef identifies the concrete resource an audited action touched.
36+
type ResourceRef struct {
37+
Type string `json:"type"`
38+
Name string `json:"name,omitempty"`
39+
}
40+
41+
// EnrichmentEntity carries the raw identifier (e.g. an app_id) that the
42+
// audit-log service resolves into the full entity during enrichment.
43+
type EnrichmentEntity struct {
44+
Identifier string `json:"identifier"`
45+
}
46+
47+
// AuditLogEvent is the canonical NATS payload published on AUDIT_LOG_TOPIC.
48+
// It is shared between the publisher (orchestrator) and the consumer
49+
// (audit-log service) so both agree on the contract.
50+
type AuditLogEvent struct {
51+
ApiPath string `json:"apiPath"`
52+
Time string `json:"time"`
53+
Resource ResourceRef `json:"resource"`
54+
Module string `json:"module"`
55+
Type string `json:"type"` // action verb, e.g. "create"
56+
Action string `json:"action"` // human sentence, e.g. "Created application 'dashboard'"
57+
UpdatedBy string `json:"updatedBy"` // acting user (email) who performed the action
58+
ClientIp string `json:"clientIp"` // client IP the request originated from
59+
RequestMethod string `json:"requestMethod"` // HTTP method of the audited request
60+
ApiResponseCode int `json:"apiResponseCode"` // HTTP status code the request returned
61+
ResponseTime time.Duration `json:"responseTime"` // request handling duration (nanoseconds)
62+
Payload map[string]interface{} `json:"payload"` // the actual request body of the audited call, always present
63+
EnrichmentContext map[string]EnrichmentEntity `json:"enrichmentContext,omitempty"`
64+
}
65+
66+
// NewAuditLogEvent constructs an event with the structural metadata parsed
67+
// from a route name. eventTime is supplied by the caller (time.Now()) so this
68+
// package stays free of implicit clock reads.
69+
func NewAuditLogEvent(apiPath string, eventTime time.Time, module AuditModule, resourceType AuditResource, action AuditAction) *AuditLogEvent {
70+
return &AuditLogEvent{
71+
ApiPath: apiPath,
72+
Time: eventTime.Format(TimeFormat),
73+
Module: module.ToString(),
74+
Type: action.ToString(),
75+
Resource: ResourceRef{Type: resourceType.ToString()},
76+
Payload: make(map[string]interface{}),
77+
EnrichmentContext: make(map[string]EnrichmentEntity),
78+
}
79+
}
80+
81+
// WithResourceName sets the human-facing name of the affected resource.
82+
func (e *AuditLogEvent) WithResourceName(name string) *AuditLogEvent {
83+
e.Resource.Name = name
84+
return e
85+
}
86+
87+
// WithActor records the acting user (email) and originating client IP. The
88+
// acting user is always available and is the "updated by" of the audited action.
89+
func (e *AuditLogEvent) WithActor(updatedBy, clientIp string) *AuditLogEvent {
90+
e.UpdatedBy = updatedBy
91+
e.ClientIp = clientIp
92+
return e
93+
}
94+
95+
// WithRequest records the request's HTTP method, response status code and
96+
// handling duration.
97+
func (e *AuditLogEvent) WithRequest(method string, apiResponseCode int, responseTime time.Duration) *AuditLogEvent {
98+
e.RequestMethod = method
99+
e.ApiResponseCode = apiResponseCode
100+
e.ResponseTime = responseTime
101+
return e
102+
}
103+
104+
// IsApiSuccess reports whether the audited request completed successfully (HTTP 2xx).
105+
// Only successful actions are published to NATS — failed or denied requests
106+
// (4xx/5xx) are not audited.
107+
func (e *AuditLogEvent) IsApiSuccess() bool {
108+
return e.ApiResponseCode >= 200 && e.ApiResponseCode < 300
109+
}
110+
111+
// WithAction sets the human-readable action sentence.
112+
func (e *AuditLogEvent) WithAction(action string) *AuditLogEvent {
113+
e.Action = action
114+
return e
115+
}
116+
117+
// WithPayloadField adds a single key/value to the payload bag.
118+
func (e *AuditLogEvent) WithPayloadField(key string, value interface{}) *AuditLogEvent {
119+
if e.Payload == nil {
120+
e.Payload = make(map[string]interface{})
121+
}
122+
e.Payload[key] = value
123+
return e
124+
}
125+
126+
// WithEnrichment records an identifier (e.g. app_id) under an entity key
127+
// (e.g. "app") for the consumer to resolve. Empty identifiers are ignored so
128+
// callers can pass through missing path vars without polluting the context.
129+
func (e *AuditLogEvent) WithEnrichment(entity, identifier string) *AuditLogEvent {
130+
if identifier == "" {
131+
return e
132+
}
133+
if e.EnrichmentContext == nil {
134+
e.EnrichmentContext = make(map[string]EnrichmentEntity)
135+
}
136+
e.EnrichmentContext[entity] = EnrichmentEntity{Identifier: identifier}
137+
return e
138+
}
139+
140+
// Marshal serializes the event to a JSON string for PubSubClientService.Publish.
141+
func (e *AuditLogEvent) Marshal() (string, error) {
142+
data, err := json.Marshal(e)
143+
if err != nil {
144+
return "", err
145+
}
146+
return string(data), nil
147+
}
148+
149+
// UnmarshalAuditLogEvent parses a NATS payload back into an event (consumer side).
150+
func UnmarshalAuditLogEvent(data string) (*AuditLogEvent, error) {
151+
event := &AuditLogEvent{}
152+
if err := json.Unmarshal([]byte(data), event); err != nil {
153+
return nil, err
154+
}
155+
return event, nil
156+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2024. Devtron Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package audit
18+
19+
import (
20+
"encoding/json"
21+
"testing"
22+
"time"
23+
)
24+
25+
func TestAuditLogEvent_MarshalShape(t *testing.T) {
26+
ts := time.Date(2026, 6, 29, 6, 7, 11, 0, time.UTC)
27+
event := NewAuditLogEvent("/orchestrator/app/edit", ts, ModuleAppManagement, ResourceApplication, ActionCreate).
28+
WithResourceName("dashboard").
29+
WithAction("Created application 'dashboard'").
30+
WithPayloadField("request_path", "/orchestrator/app/edit").
31+
WithPayloadField("http_method", "POST").
32+
WithEnrichment(EntityApp, "42").
33+
WithEnrichment(EntityUser, "7").
34+
WithEnrichment(EntityCluster, "") // empty identifier must be ignored
35+
36+
out, err := event.Marshal()
37+
if err != nil {
38+
t.Fatalf("marshal: %v", err)
39+
}
40+
41+
// Round-trip into a generic map to assert the wire contract.
42+
var m map[string]interface{}
43+
if err := json.Unmarshal([]byte(out), &m); err != nil {
44+
t.Fatalf("unmarshal: %v", err)
45+
}
46+
47+
if m["apiPath"] != "/orchestrator/app/edit" {
48+
t.Errorf("apiPath = %v", m["apiPath"])
49+
}
50+
if m["module"] != "application_management" {
51+
t.Errorf("module = %v", m["module"])
52+
}
53+
if m["type"] != "create" {
54+
t.Errorf("type = %v", m["type"])
55+
}
56+
if m["time"] != "2026-06-29T06:07:11+0000" {
57+
t.Errorf("time = %v", m["time"])
58+
}
59+
resource := m["resource"].(map[string]interface{})
60+
if resource["type"] != "application" || resource["name"] != "dashboard" {
61+
t.Errorf("resource = %v", resource)
62+
}
63+
enrich := m["enrichmentContext"].(map[string]interface{})
64+
if _, ok := enrich[EntityCluster]; ok {
65+
t.Errorf("empty-identifier entity must be dropped, got %v", enrich)
66+
}
67+
if enrich[EntityApp].(map[string]interface{})["identifier"] != "42" {
68+
t.Errorf("app identifier = %v", enrich[EntityApp])
69+
}
70+
71+
// Round-trip back to a typed event.
72+
back, err := UnmarshalAuditLogEvent(out)
73+
if err != nil {
74+
t.Fatalf("unmarshal event: %v", err)
75+
}
76+
if back.Resource.Name != "dashboard" || back.EnrichmentContext[EntityUser].Identifier != "7" {
77+
t.Errorf("round-trip mismatch: %+v", back)
78+
}
79+
}

0 commit comments

Comments
 (0)