Skip to content

Commit 3d6d51f

Browse files
authored
feat: audit log api (#3898)
1 parent ad24404 commit 3d6d51f

File tree

12 files changed

+426
-60
lines changed

12 files changed

+426
-60
lines changed

cli/portforward.go

-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"golang.org/x/xerrors"
1818

1919
"cdr.dev/slog"
20-
2120
"github.com/coder/coder/agent"
2221
"github.com/coder/coder/cli/cliui"
2322
"github.com/coder/coder/codersdk"

coderd/audit.go

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package coderd
2+
3+
import (
4+
"encoding/json"
5+
"net"
6+
"net/http"
7+
"net/netip"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"github.com/tabbed/pqtype"
12+
13+
"github.com/coder/coder/coderd/database"
14+
"github.com/coder/coder/coderd/httpapi"
15+
"github.com/coder/coder/coderd/httpmw"
16+
"github.com/coder/coder/coderd/rbac"
17+
"github.com/coder/coder/codersdk"
18+
)
19+
20+
func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
21+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
22+
httpapi.Forbidden(rw)
23+
return
24+
}
25+
26+
ctx := r.Context()
27+
page, ok := parsePagination(rw, r)
28+
if !ok {
29+
return
30+
}
31+
32+
dblogs, err := api.Database.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
33+
Offset: int32(page.Offset),
34+
Limit: int32(page.Limit),
35+
})
36+
if err != nil {
37+
httpapi.InternalServerError(rw, err)
38+
return
39+
}
40+
41+
httpapi.Write(rw, http.StatusOK, codersdk.AuditLogResponse{
42+
AuditLogs: convertAuditLogs(dblogs),
43+
})
44+
}
45+
46+
func (api *API) auditLogCount(rw http.ResponseWriter, r *http.Request) {
47+
ctx := r.Context()
48+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
49+
httpapi.Forbidden(rw)
50+
return
51+
}
52+
53+
count, err := api.Database.GetAuditLogCount(ctx)
54+
if err != nil {
55+
httpapi.InternalServerError(rw, err)
56+
return
57+
}
58+
59+
httpapi.Write(rw, http.StatusOK, codersdk.AuditLogCountResponse{
60+
Count: count,
61+
})
62+
}
63+
64+
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
65+
ctx := r.Context()
66+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
67+
httpapi.Forbidden(rw)
68+
return
69+
}
70+
71+
key := httpmw.APIKey(r)
72+
user, err := api.Database.GetUserByID(ctx, key.UserID)
73+
if err != nil {
74+
httpapi.InternalServerError(rw, err)
75+
return
76+
}
77+
78+
diff, err := json.Marshal(codersdk.AuditDiff{
79+
"foo": codersdk.AuditDiffField{Old: "bar", New: "baz"},
80+
})
81+
if err != nil {
82+
httpapi.InternalServerError(rw, err)
83+
return
84+
}
85+
86+
ipRaw, _, _ := net.SplitHostPort(r.RemoteAddr)
87+
ip := net.ParseIP(ipRaw)
88+
ipNet := pqtype.Inet{}
89+
if ip != nil {
90+
ipNet = pqtype.Inet{
91+
IPNet: net.IPNet{
92+
IP: ip,
93+
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
94+
},
95+
Valid: true,
96+
}
97+
}
98+
99+
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
100+
ID: uuid.New(),
101+
Time: time.Now(),
102+
UserID: user.ID,
103+
Ip: ipNet,
104+
UserAgent: r.UserAgent(),
105+
ResourceType: database.ResourceTypeUser,
106+
ResourceID: user.ID,
107+
ResourceTarget: user.Username,
108+
Action: database.AuditActionWrite,
109+
Diff: diff,
110+
StatusCode: http.StatusOK,
111+
AdditionalFields: []byte("{}"),
112+
})
113+
if err != nil {
114+
httpapi.InternalServerError(rw, err)
115+
return
116+
}
117+
118+
rw.WriteHeader(http.StatusNoContent)
119+
}
120+
121+
func convertAuditLogs(dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
122+
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
123+
124+
for _, dblog := range dblogs {
125+
alogs = append(alogs, convertAuditLog(dblog))
126+
}
127+
128+
return alogs
129+
}
130+
131+
func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
132+
ip, _ := netip.AddrFromSlice(dblog.Ip.IPNet.IP)
133+
134+
diff := codersdk.AuditDiff{}
135+
_ = json.Unmarshal(dblog.Diff, &diff)
136+
137+
var user *codersdk.User
138+
if dblog.UserUsername.Valid {
139+
user = &codersdk.User{
140+
ID: dblog.UserID,
141+
Username: dblog.UserUsername.String,
142+
Email: dblog.UserEmail.String,
143+
CreatedAt: dblog.UserCreatedAt.Time,
144+
Status: codersdk.UserStatus(dblog.UserStatus),
145+
Roles: []codersdk.Role{},
146+
}
147+
148+
for _, roleName := range dblog.UserRoles {
149+
rbacRole, _ := rbac.RoleByName(roleName)
150+
user.Roles = append(user.Roles, convertRole(rbacRole))
151+
}
152+
}
153+
154+
return codersdk.AuditLog{
155+
ID: dblog.ID,
156+
RequestID: dblog.RequestID,
157+
Time: dblog.Time,
158+
OrganizationID: dblog.OrganizationID,
159+
IP: ip,
160+
UserAgent: dblog.UserAgent,
161+
ResourceType: codersdk.ResourceType(dblog.ResourceType),
162+
ResourceID: dblog.ResourceID,
163+
ResourceTarget: dblog.ResourceTarget,
164+
ResourceIcon: dblog.ResourceIcon,
165+
Action: codersdk.AuditAction(dblog.Action),
166+
Diff: diff,
167+
StatusCode: dblog.StatusCode,
168+
AdditionalFields: dblog.AdditionalFields,
169+
Description: "",
170+
User: user,
171+
}
172+
}

coderd/audit_test.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/coderd/coderdtest"
10+
"github.com/coder/coder/codersdk"
11+
)
12+
13+
func TestAuditLogs(t *testing.T) {
14+
t.Parallel()
15+
16+
t.Run("OK", func(t *testing.T) {
17+
t.Parallel()
18+
19+
ctx := context.Background()
20+
client := coderdtest.New(t, nil)
21+
_ = coderdtest.CreateFirstUser(t, client)
22+
23+
err := client.CreateTestAuditLog(ctx)
24+
require.NoError(t, err)
25+
26+
count, err := client.AuditLogCount(ctx)
27+
require.NoError(t, err)
28+
29+
alogs, err := client.AuditLogs(ctx, codersdk.Pagination{Limit: 1})
30+
require.NoError(t, err)
31+
32+
require.Equal(t, int64(1), count.Count)
33+
require.Len(t, alogs.AuditLogs, 1)
34+
})
35+
}

coderd/coderd.go

+9
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,15 @@ func New(options *Options) *API {
220220
})
221221
})
222222
})
223+
r.Route("/audit", func(r chi.Router) {
224+
r.Use(
225+
apiKeyMiddleware,
226+
)
227+
228+
r.Get("/", api.auditLogs)
229+
r.Get("/count", api.auditLogCount)
230+
r.Post("/testgenerate", api.generateFakeAuditLog)
231+
})
223232
r.Route("/files", func(r chi.Router) {
224233
r.Use(
225234
apiKeyMiddleware,

coderd/database/databasefake/databasefake.go

+39-23
Original file line numberDiff line numberDiff line change
@@ -2303,43 +2303,59 @@ func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error
23032303
return sql.ErrNoRows
23042304
}
23052305

2306-
func (q *fakeQuerier) GetAuditLogsBefore(_ context.Context, arg database.GetAuditLogsBeforeParams) ([]database.AuditLog, error) {
2306+
func (q *fakeQuerier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) {
23072307
q.mutex.RLock()
23082308
defer q.mutex.RUnlock()
23092309

2310-
logs := make([]database.AuditLog, 0)
2311-
start := database.AuditLog{}
2312-
2313-
if arg.ID != uuid.Nil {
2314-
for _, alog := range q.auditLogs {
2315-
if alog.ID == arg.ID {
2316-
start = alog
2317-
break
2318-
}
2319-
}
2320-
} else {
2321-
start.ID = uuid.New()
2322-
start.Time = arg.StartTime
2323-
}
2324-
2325-
if start.ID == uuid.Nil {
2326-
return nil, sql.ErrNoRows
2327-
}
2310+
logs := make([]database.GetAuditLogsOffsetRow, 0, arg.Limit)
23282311

23292312
// q.auditLogs are already sorted by time DESC, so no need to sort after the fact.
23302313
for _, alog := range q.auditLogs {
2331-
if alog.Time.Before(start.Time) {
2332-
logs = append(logs, alog)
2333-
}
2314+
if arg.Offset > 0 {
2315+
arg.Offset--
2316+
continue
2317+
}
2318+
2319+
user, err := q.GetUserByID(ctx, alog.UserID)
2320+
userValid := err == nil
2321+
2322+
logs = append(logs, database.GetAuditLogsOffsetRow{
2323+
ID: alog.ID,
2324+
RequestID: alog.RequestID,
2325+
OrganizationID: alog.OrganizationID,
2326+
Ip: alog.Ip,
2327+
UserAgent: alog.UserAgent,
2328+
ResourceType: database.ResourceType(alog.UserAgent),
2329+
ResourceID: alog.ResourceID,
2330+
ResourceTarget: alog.ResourceTarget,
2331+
ResourceIcon: alog.ResourceIcon,
2332+
Action: alog.Action,
2333+
Diff: alog.Diff,
2334+
StatusCode: alog.StatusCode,
2335+
AdditionalFields: alog.AdditionalFields,
2336+
UserID: alog.UserID,
2337+
UserUsername: sql.NullString{String: user.Username, Valid: userValid},
2338+
UserEmail: sql.NullString{String: user.Email, Valid: userValid},
2339+
UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid},
2340+
UserStatus: user.Status,
2341+
UserRoles: user.RBACRoles,
2342+
})
23342343

2335-
if len(logs) >= int(arg.RowLimit) {
2344+
if len(logs) >= int(arg.Limit) {
23362345
break
23372346
}
23382347
}
23392348

23402349
return logs, nil
23412350
}
23422351

2352+
func (q *fakeQuerier) GetAuditLogCount(_ context.Context) (int64, error) {
2353+
q.mutex.RLock()
2354+
defer q.mutex.RUnlock()
2355+
2356+
return int64(len(q.auditLogs)), nil
2357+
}
2358+
23432359
func (q *fakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAuditLogParams) (database.AuditLog, error) {
23442360
q.mutex.Lock()
23452361
defer q.mutex.Unlock()

coderd/database/querier.go

+3-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)