Skip to content

Commit 2fd18b1

Browse files
authored
feat: audit logs for "Stop Job" action (#392)
## 📝 Description Added auditing for stop job operation via Web and API/CLI. Introduced audit middleware to the `public-api-gateway` service to capture and log these actions. The middleware is designed to be extensible for future auditing of other API operations. Related [task](renderedtext/tasks#8123). ## ✅ Checklist - [x] I have tested this change - [x] ~This change requires documentation update~ - N/A
1 parent 4c76ef3 commit 2fd18b1

File tree

22 files changed

+3787
-29
lines changed

22 files changed

+3787
-29
lines changed

.semaphore/daily-builds.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1993,6 +1993,9 @@ blocks:
19931993
- name: "Test"
19941994
commands:
19951995
- make test
1996+
- name: "E2E Test"
1997+
commands:
1998+
- make test.e2e
19961999
- name: "Lint"
19972000
commands:
19982001
- make lint

.semaphore/semaphore.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,6 +2191,9 @@ blocks:
21912191
- name: "Test"
21922192
commands:
21932193
- make test
2194+
- name: "E2E Test"
2195+
commands:
2196+
- make test.e2e
21942197
- name: "Lint"
21952198
commands:
21962199
- make lint

front/lib/front/audit/events_decorator.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ defmodule Front.Audit.EventsDecorator do
3333
field(:pipeline, Front.Models.Pipeline.t(), enforce: false)
3434
field(:has_pipeline, String.t(), enforce: false, default: false)
3535

36+
field(:job_id, String.t(), enforce: false)
37+
field(:job, Front.Models.Job.t(), enforce: false)
38+
field(:has_job, String.t(), enforce: false, default: false)
39+
3640
field(:agent, Map.t(), enforce: false)
3741
end
3842
end
@@ -78,6 +82,10 @@ defmodule Front.Audit.EventsDecorator do
7882
has_workflow: false,
7983
pipeline_id: Map.get(metadata, "pipeline_id", nil),
8084

85+
# initialy setting it to false, later the preloader can change it
86+
has_job: false,
87+
job_id: Map.get(metadata, "job_id", nil),
88+
8189
# inject agent data if the event is related to a self-hosted agent
8290
agent: decorate_agent(event, metadata)
8391
)

front/lib/front/audit/events_decorator/preloader.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,18 @@ defmodule Front.Audit.EventsDecorator.Preloader do
1919
project_ids = extract_unique_id_list(events, :project_id)
2020
workflow_ids = extract_unique_id_list(events, :workflow_id)
2121
pipeline_ids = extract_unique_id_list(events, :pipeline_id)
22+
job_ids = extract_unique_id_list(events, :job_id)
2223

2324
projects = Front.Models.Project.find_many_by_ids(project_ids)
2425
workflows = Front.Models.Workflow.find_many_by_ids(workflow_ids)
2526
pipelines = Front.Models.Pipeline.find_many(pipeline_ids)
27+
jobs = find_jobs_by_ids(job_ids)
2628

2729
inject(events, %{
2830
projects: remove_nils(projects),
2931
workflows: remove_nils(workflows),
30-
pipelines: remove_nils(pipelines)
32+
pipelines: remove_nils(pipelines),
33+
jobs: remove_nils(jobs)
3134
})
3235
end
3336

@@ -36,11 +39,13 @@ defmodule Front.Audit.EventsDecorator.Preloader do
3639
project = Enum.find(data.projects, fn p -> p.id == event.project_id end)
3740
workflow = Enum.find(data.workflows, fn w -> w.id == event.workflow_id end)
3841
pipeline = Enum.find(data.pipelines, fn p -> p.id == event.pipeline_id end)
42+
job = Enum.find(data.jobs, fn j -> j.id == event.job_id end)
3943

4044
event
4145
|> add_if_not_nil(project, :project, :has_project)
4246
|> add_if_not_nil(workflow, :workflow, :has_workflow)
4347
|> add_if_not_nil(pipeline, :pipeline, :has_pipeline)
48+
|> add_if_not_nil(job, :job, :has_job)
4449
end)
4550
end
4651

@@ -58,4 +63,14 @@ defmodule Front.Audit.EventsDecorator.Preloader do
5863
end
5964

6065
defp remove_nils(arr), do: Enum.filter(arr, fn e -> e != nil end)
66+
67+
defp find_jobs_by_ids([]), do: []
68+
69+
defp find_jobs_by_ids([id | ids]) do
70+
Front.Models.Job.find(id)
71+
|> case do
72+
nil -> find_jobs_by_ids(ids)
73+
job -> [job | find_jobs_by_ids(ids)]
74+
end
75+
end
6176
end

front/lib/front_web/controllers/job_controller.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule FrontWeb.JobController do
22
require Logger
33
use FrontWeb, :controller
44

5-
alias Front.Async
5+
alias Front.{Async, Audit}
66
alias Front.MemoryCookie
77
alias Front.Models
88
alias FrontWeb.Plugs.{FetchPermissions, Header, PageAccess, PublicPageAccess, PutProjectAssigns}
@@ -216,6 +216,8 @@ defmodule FrontWeb.JobController do
216216

217217
case Front.Models.Job.stop(job_id, user_id) do
218218
{:ok, _} ->
219+
audit_log(conn, :Stopped, user_id, job_id)
220+
219221
conn
220222
|> put_flash(:notice, "Job will be stopped shortly.")
221223
|> redirect(to: job_path(conn, :show, job_id))
@@ -311,6 +313,21 @@ defmodule FrontWeb.JobController do
311313
end)
312314
end
313315

316+
def audit_log(conn, action, user_id, job_id) do
317+
conn
318+
|> Audit.new(:Job, action)
319+
|> Audit.add(description: audit_desc(action))
320+
|> Audit.add(resource_id: job_id)
321+
|> Audit.metadata(requester_id: user_id)
322+
|> Audit.metadata(project_id: conn.assigns.project.id)
323+
|> Audit.metadata(project_name: conn.assigns.project.name)
324+
|> Audit.metadata(pipeline_id: conn.assigns.job.ppl_id)
325+
|> Audit.metadata(job_id: conn.assigns.job.id)
326+
|> Audit.log()
327+
end
328+
329+
defp audit_desc(:Stopped), do: "Stopped the job"
330+
314331
# Private
315332

316333
defp send_first_chunk(conn, next) do

front/lib/front_web/templates/audit/index.html.eex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
<div>Pipeline: <%= link event.pipeline.name, to: workflow_path(@conn, :show, event.workflow.id, pipeline_id: event.pipeline.id) %></div>
3535
<% end %>
3636
<% end %>
37+
<%= if event.has_job do %>
38+
<div>Job: <%= link event.job.name, to: job_path(@conn, :show, event.job.id) %></div>
39+
<% end %>
3740
<% end %>
3841
<div><%= event.description %></div>
3942
</div>

public-api-gateway/Dockerfile

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,34 @@ RUN echo "Build of $APP_NAME started"
1313
RUN apt-get update -y && apt-get install --no-install-recommends -y ca-certificates unzip curl libc-bin libc6 \
1414
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
1515

16-
WORKDIR /app
17-
COPY api api
18-
COPY go.mod go.mod
19-
COPY go.sum go.sum
20-
COPY main.go main.go
21-
22-
FROM base AS dev
23-
24-
COPY test test
25-
COPY scripts scripts
26-
COPY lint.toml lint.toml
27-
2816
WORKDIR /tmp
2917
RUN curl -sL https://github.com/google/protobuf/releases/download/v3.3.0/protoc-3.3.0-linux-x86_64.zip -o protoc && \
3018
unzip protoc && \
3119
mv bin/protoc /usr/local/bin/protoc
3220

3321
WORKDIR /app
22+
3423
RUN go install github.com/mgechev/[email protected]
3524
RUN go install gotest.tools/[email protected]
3625
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
3726
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
27+
28+
FROM base AS dev
29+
30+
WORKDIR /app
31+
32+
COPY api api
33+
COPY protos protos
34+
COPY go.mod go.mod
35+
COPY go.sum go.sum
36+
COPY main.go main.go
37+
3838
RUN rm -rf build && CGO_ENABLED=0 go build -o build/server main.go
3939

40+
COPY test test
41+
COPY scripts scripts
42+
COPY lint.toml lint.toml
43+
4044
CMD [ "/bin/bash", "-c \"while sleep 1000; do :; done\"" ]
4145

4246
FROM ${RUNNER_IMAGE} AS runner

public-api-gateway/Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ api.checkout:
2929
bin.build:
3030
docker compose run --remove-orphans --rm $(VOLUME_BIND) app sh -c "rm -rf build && CGO_ENABLED=0 go build -o build/server main.go"
3131

32-
test: bin.build
32+
test.e2e: bin.build
3333
docker compose run --remove-orphans --rm $(VOLUME_BIND) app bash ./test/test.sh
3434

35+
test:
36+
docker compose run --remove-orphans --rm $(VOLUME_BIND) app gotestsum --format short-verbose --junitfile out/test-reports.xml --packages="./..." -- -p 1
37+
3538
lint:
3639
docker compose run --remove-orphans --rm $(VOLUME_BIND) app revive -formatter friendly -config lint.toml ./...
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package clients
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
"github.com/golang/glog"
10+
"github.com/renderedtext/go-tackle"
11+
"google.golang.org/protobuf/proto"
12+
13+
auditProto "github.com/semaphoreio/semaphore/public-api-gateway/protos/audit"
14+
)
15+
16+
// AuditClient provides methods for sending audit events
17+
type AuditClient struct {
18+
tacklePublisher *tackle.Publisher
19+
amqpURL string
20+
}
21+
22+
// AuditEventOptions contains options for creating an audit event
23+
type AuditEventOptions struct {
24+
// UserID of the user performing the action
25+
UserID string
26+
// OrgID of the organization where the action is performed
27+
OrgID string
28+
// Resource type that is being audited
29+
Resource auditProto.Event_Resource
30+
// Operation being performed
31+
Operation auditProto.Event_Operation
32+
// Description of the audit event
33+
Description string
34+
// ResourceID of the affected resource
35+
ResourceID string
36+
// ResourceName of the affected resource
37+
ResourceName string
38+
// Medium through which the action was performed (e.g. API, CLI)
39+
Medium auditProto.Event_Medium
40+
// Additional metadata
41+
Metadata map[string]string
42+
// IP address of the client
43+
IPAddress string
44+
// Username of the user
45+
Username string
46+
}
47+
48+
// NewAuditClient creates a new audit client
49+
func NewAuditClient(amqpURL string) (*AuditClient, error) {
50+
client := &AuditClient{
51+
amqpURL: amqpURL,
52+
}
53+
54+
if amqpURL == "" {
55+
return nil, fmt.Errorf("AMQP URL is required")
56+
}
57+
58+
tacklePublisher, err := tackle.NewPublisher(amqpURL, tackle.PublisherOptions{
59+
ConnectionName: clientConnectionName(),
60+
ConnectionTimeout: 5 * time.Second,
61+
})
62+
63+
if err != nil {
64+
return nil, fmt.Errorf("failed to create AMQP publisher: %w", err)
65+
}
66+
67+
client.tacklePublisher = tacklePublisher
68+
69+
return client, nil
70+
}
71+
72+
// SendAuditEvent sends an audit event via AMQP
73+
func (c *AuditClient) SendAuditEvent(ctx context.Context, event *auditProto.Event) error {
74+
data, err := proto.Marshal(event)
75+
if err != nil {
76+
return fmt.Errorf("error marshaling audit event: %w", err)
77+
}
78+
79+
err = c.tacklePublisher.PublishWithContext(ctx, &tackle.PublishParams{
80+
Body: data,
81+
Exchange: "audit",
82+
RoutingKey: "log",
83+
})
84+
85+
if err != nil {
86+
glog.Errorf("Error publishing audit event: %v", err)
87+
return fmt.Errorf("error publishing audit event: %w", err)
88+
}
89+
90+
glog.Infof("Audit event published via AMQP: resource=%s, operation=%s, resource_id=%s, operation_id=%s", event.Resource.String(), event.Operation.String(), event.ResourceId, event.OperationId)
91+
92+
return nil
93+
}
94+
95+
func clientConnectionName() string {
96+
hostname := os.Getenv("HOSTNAME")
97+
if hostname == "" {
98+
return "public-api-gateway"
99+
}
100+
101+
return hostname
102+
}

0 commit comments

Comments
 (0)