Skip to content

Commit 6540746

Browse files
authored
Add audit links/kira pilot (coder#5156)
* got links working * added translations * fixed translation * added translation for unavailable ip * added support for group, template, user links * cleaned up string * added deleted label * querying for workspace id * remove prints * fix/write tests * PR feedback pt 1 * PR feedback part 2
1 parent fa64155 commit 6540746

File tree

12 files changed

+298
-99
lines changed

12 files changed

+298
-99
lines changed

coderd/audit.go

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd
22

33
import (
4+
"context"
45
"database/sql"
56
"encoding/json"
67
"fmt"
@@ -13,7 +14,9 @@ import (
1314

1415
"github.com/google/uuid"
1516
"github.com/tabbed/pqtype"
17+
"golang.org/x/xerrors"
1618

19+
"cdr.dev/slog"
1720
"github.com/coder/coder/coderd/database"
1821
"github.com/coder/coder/coderd/httpapi"
1922
"github.com/coder/coder/coderd/httpmw"
@@ -69,7 +72,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
6972
}
7073

7174
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
72-
AuditLogs: convertAuditLogs(dblogs),
75+
AuditLogs: api.convertAuditLogs(ctx, dblogs),
7376
Count: dblogs[0].Count,
7477
})
7578
}
@@ -147,17 +150,17 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
147150
rw.WriteHeader(http.StatusNoContent)
148151
}
149152

150-
func convertAuditLogs(dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
153+
func (api *API) convertAuditLogs(ctx context.Context, dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
151154
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
152155

153156
for _, dblog := range dblogs {
154-
alogs = append(alogs, convertAuditLog(dblog))
157+
alogs = append(alogs, api.convertAuditLog(ctx, dblog))
155158
}
156159

157160
return alogs
158161
}
159162

160-
func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
163+
func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
161164
ip, _ := netip.AddrFromSlice(dblog.Ip.IPNet.IP)
162165

163166
diff := codersdk.AuditDiff{}
@@ -182,6 +185,14 @@ func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
182185
}
183186
}
184187

188+
isDeleted := api.auditLogIsResourceDeleted(ctx, dblog)
189+
var resourceLink string
190+
if isDeleted {
191+
resourceLink = ""
192+
} else {
193+
resourceLink = api.auditLogResourceLink(ctx, dblog)
194+
}
195+
185196
return codersdk.AuditLog{
186197
ID: dblog.ID,
187198
RequestID: dblog.RequestID,
@@ -197,34 +208,123 @@ func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
197208
Diff: diff,
198209
StatusCode: dblog.StatusCode,
199210
AdditionalFields: dblog.AdditionalFields,
200-
Description: auditLogDescription(dblog),
201211
User: user,
212+
Description: auditLogDescription(dblog),
213+
ResourceLink: resourceLink,
214+
IsDeleted: isDeleted,
202215
}
203216
}
204217

205218
func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
206-
str := fmt.Sprintf("{user} %s %s",
219+
str := fmt.Sprintf("{user} %s",
207220
codersdk.AuditAction(alog.Action).FriendlyString(),
208-
codersdk.ResourceType(alog.ResourceType).FriendlyString(),
209221
)
210222

211-
// Strings for workspace_builds follow the below format:
212-
// "{user} started workspace build for {target}"
213-
// where target is a workspace instead of the workspace build,
223+
// Strings for starting/stopping workspace builds follow the below format:
224+
// "{user} started build for workspace {target}"
225+
// where target is a workspace instead of a workspace build
214226
// passed in on the FE via AuditLog.AdditionalFields rather than derived in request.go:35
215-
if alog.ResourceType == database.ResourceTypeWorkspaceBuild {
216-
str += " for"
227+
if alog.ResourceType == database.ResourceTypeWorkspaceBuild && alog.Action != database.AuditActionDelete {
228+
str += " build for"
217229
}
218230

219-
// We don't display the name for git ssh keys. It's fairly long and doesn't
231+
// We don't display the name (target) for git ssh keys. It's fairly long and doesn't
220232
// make too much sense to display.
221-
if alog.ResourceType != database.ResourceTypeGitSshKey {
222-
str += " {target}"
233+
if alog.ResourceType == database.ResourceTypeGitSshKey {
234+
str += fmt.Sprintf(" the %s",
235+
codersdk.ResourceType(alog.ResourceType).FriendlyString())
236+
return str
223237
}
224238

239+
str += fmt.Sprintf(" %s",
240+
codersdk.ResourceType(alog.ResourceType).FriendlyString())
241+
242+
str += " {target}"
243+
225244
return str
226245
}
227246

247+
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
248+
switch alog.ResourceType {
249+
case database.ResourceTypeTemplate:
250+
template, err := api.Database.GetTemplateByID(ctx, alog.ResourceID)
251+
if err != nil {
252+
if xerrors.Is(err, sql.ErrNoRows) {
253+
return true
254+
}
255+
api.Logger.Error(ctx, "fetch template", slog.Error(err))
256+
}
257+
return template.Deleted
258+
case database.ResourceTypeUser:
259+
user, err := api.Database.GetUserByID(ctx, alog.ResourceID)
260+
if err != nil {
261+
if xerrors.Is(err, sql.ErrNoRows) {
262+
return true
263+
}
264+
api.Logger.Error(ctx, "fetch user", slog.Error(err))
265+
}
266+
return user.Deleted
267+
case database.ResourceTypeWorkspace:
268+
workspace, err := api.Database.GetWorkspaceByID(ctx, alog.ResourceID)
269+
if err != nil {
270+
if xerrors.Is(err, sql.ErrNoRows) {
271+
return true
272+
}
273+
api.Logger.Error(ctx, "fetch workspace", slog.Error(err))
274+
}
275+
return workspace.Deleted
276+
case database.ResourceTypeWorkspaceBuild:
277+
workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.ResourceID)
278+
if err != nil {
279+
if xerrors.Is(err, sql.ErrNoRows) {
280+
return true
281+
}
282+
api.Logger.Error(ctx, "fetch workspace build", slog.Error(err))
283+
}
284+
// We use workspace as a proxy for workspace build here
285+
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
286+
if err != nil {
287+
if xerrors.Is(err, sql.ErrNoRows) {
288+
return true
289+
}
290+
api.Logger.Error(ctx, "fetch workspace", slog.Error(err))
291+
}
292+
return workspace.Deleted
293+
default:
294+
return false
295+
}
296+
}
297+
298+
type AdditionalFields struct {
299+
WorkspaceName string
300+
BuildNumber string
301+
}
302+
303+
func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow) string {
304+
switch alog.ResourceType {
305+
case database.ResourceTypeTemplate:
306+
return fmt.Sprintf("/templates/%s",
307+
alog.ResourceTarget)
308+
case database.ResourceTypeUser:
309+
return fmt.Sprintf("/users?filter=%s",
310+
alog.ResourceTarget)
311+
case database.ResourceTypeWorkspace:
312+
return fmt.Sprintf("/@%s/%s",
313+
alog.UserUsername.String, alog.ResourceTarget)
314+
case database.ResourceTypeWorkspaceBuild:
315+
additionalFieldsBytes := []byte(alog.AdditionalFields)
316+
var additionalFields AdditionalFields
317+
err := json.Unmarshal(additionalFieldsBytes, &additionalFields)
318+
if err != nil {
319+
api.Logger.Error(ctx, "unmarshal workspace name", slog.Error(err))
320+
}
321+
return fmt.Sprintf("/@%s/%s/builds/%s",
322+
alog.UserUsername.String, additionalFields.WorkspaceName, additionalFields.BuildNumber)
323+
default:
324+
return ""
325+
}
326+
}
327+
228328
// auditSearchQuery takes a query string and returns the auditLog filter.
229329
// It also can return the list of validation errors to return to the api.
230330
func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) {

coderd/audit_test.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"testing"
66
"time"
77

8-
"github.com/google/uuid"
98
"github.com/stretchr/testify/require"
109

1110
"github.com/coder/coder/coderd/coderdtest"
@@ -20,9 +19,11 @@ func TestAuditLogs(t *testing.T) {
2019

2120
ctx := context.Background()
2221
client := coderdtest.New(t, nil)
23-
_ = coderdtest.CreateFirstUser(t, client)
22+
user := coderdtest.CreateFirstUser(t, client)
2423

25-
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{})
24+
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
25+
ResourceID: user.UserID,
26+
})
2627
require.NoError(t, err)
2728

2829
alogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
@@ -43,22 +44,26 @@ func TestAuditLogsFilter(t *testing.T) {
4344
t.Run("Filter", func(t *testing.T) {
4445
t.Parallel()
4546

46-
ctx := context.Background()
47-
client := coderdtest.New(t, nil)
48-
_ = coderdtest.CreateFirstUser(t, client)
49-
userResourceID := uuid.New()
47+
var (
48+
ctx = context.Background()
49+
client = coderdtest.New(t, nil)
50+
user = coderdtest.CreateFirstUser(t, client)
51+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
52+
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
53+
)
5054

5155
// Create two logs with "Create"
5256
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
5357
Action: codersdk.AuditActionCreate,
5458
ResourceType: codersdk.ResourceTypeTemplate,
59+
ResourceID: template.ID,
5560
Time: time.Date(2022, 8, 15, 14, 30, 45, 100, time.UTC), // 2022-8-15 14:30:45
5661
})
5762
require.NoError(t, err)
5863
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
5964
Action: codersdk.AuditActionCreate,
6065
ResourceType: codersdk.ResourceTypeUser,
61-
ResourceID: userResourceID,
66+
ResourceID: user.UserID,
6267
Time: time.Date(2022, 8, 16, 14, 30, 45, 100, time.UTC), // 2022-8-16 14:30:45
6368
})
6469
require.NoError(t, err)
@@ -67,7 +72,7 @@ func TestAuditLogsFilter(t *testing.T) {
6772
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
6873
Action: codersdk.AuditActionDelete,
6974
ResourceType: codersdk.ResourceTypeUser,
70-
ResourceID: userResourceID,
75+
ResourceID: user.UserID,
7176
Time: time.Date(2022, 8, 15, 14, 30, 45, 100, time.UTC), // 2022-8-15 14:30:45
7277
})
7378
require.NoError(t, err)
@@ -110,7 +115,7 @@ func TestAuditLogsFilter(t *testing.T) {
110115
},
111116
{
112117
Name: "FilterByResourceID",
113-
SearchQuery: "resource_id:" + userResourceID.String(),
118+
SearchQuery: "resource_id:" + user.UserID.String(),
114119
ExpectedResult: 2,
115120
},
116121
{

coderd/provisionerdserver/provisionerdserver.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"reflect"
12+
"strconv"
1213
"sync"
1314
"sync/atomic"
1415
"time"
@@ -537,10 +538,11 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p
537538
if getWorkspaceErr != nil {
538539
server.Logger.Error(ctx, "failed to create audit log - get workspace err", slog.Error(err))
539540
} else {
540-
// We pass the workspace name to the Auditor so that it
541-
// can form a friendly string for the user.
541+
// We pass the below information to the Auditor so that it
542+
// can form a friendly string for the user to view in the UI.
542543
workspaceResourceInfo := map[string]string{
543544
"workspaceName": workspace.Name,
545+
"buildNumber": strconv.FormatInt(int64(build.BuildNumber), 10),
544546
}
545547

546548
wriBytes, err := json.Marshal(workspaceResourceInfo)
@@ -752,10 +754,11 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
752754
auditor := server.Auditor.Load()
753755
auditAction := auditActionFromTransition(workspaceBuild.Transition)
754756

755-
// We pass the workspace name to the Auditor so that it
756-
// can form a friendly string for the user.
757+
// We pass the below information to the Auditor so that it
758+
// can form a friendly string for the user to view in the UI.
757759
workspaceResourceInfo := map[string]string{
758760
"workspaceName": workspace.Name,
761+
"buildNumber": strconv.FormatInt(int64(workspaceBuild.BuildNumber), 10),
759762
}
760763

761764
wriBytes, err := json.Marshal(workspaceResourceInfo)

codersdk/audit.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ func (r ResourceType) FriendlyString() string {
3838
case ResourceTypeWorkspace:
3939
return "workspace"
4040
case ResourceTypeWorkspaceBuild:
41-
return "workspace build"
41+
// workspace builds have a unique friendly string
42+
// see coderd/audit.go:298 for explanation
43+
return "workspace"
4244
case ResourceTypeGitSSHKey:
4345
return "git ssh key"
4446
case ResourceTypeAPIKey:
@@ -102,6 +104,8 @@ type AuditLog struct {
102104
StatusCode int32 `json:"status_code"`
103105
AdditionalFields json.RawMessage `json:"additional_fields"`
104106
Description string `json:"description"`
107+
ResourceLink string `json:"resource_link"`
108+
IsDeleted bool `json:"is_deleted"`
105109

106110
User *User `json:"user"`
107111
}

site/src/api/typesGenerated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export interface AuditLog {
6767
readonly status_code: number
6868
readonly additional_fields: Record<string, string>
6969
readonly description: string
70+
readonly resource_link: string
71+
readonly is_deleted: boolean
7072
readonly user?: User
7173
}
7274

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
MockAuditLog,
3+
MockAuditLogWithWorkspaceBuild,
4+
} from "testHelpers/entities"
5+
import { AuditLogDescription } from "./AuditLogDescription"
6+
import { render } from "../../testHelpers/renderHelpers"
7+
import { screen } from "@testing-library/react"
8+
9+
const getByTextContent = (text: string) => {
10+
return screen.getByText((_, element) => {
11+
const hasText = (element: Element | null) => element?.textContent === text
12+
const elementHasText = hasText(element)
13+
const childrenDontHaveText = Array.from(element?.children || []).every(
14+
(child) => !hasText(child),
15+
)
16+
return elementHasText && childrenDontHaveText
17+
})
18+
}
19+
describe("AuditLogDescription", () => {
20+
it("renders the correct string for a workspace create audit log", async () => {
21+
render(<AuditLogDescription auditLog={MockAuditLog} />)
22+
23+
expect(
24+
getByTextContent("TestUser created workspace bruno-dev"),
25+
).toBeDefined()
26+
})
27+
28+
it("renders the correct string for a workspace_build stop audit log", async () => {
29+
render(<AuditLogDescription auditLog={MockAuditLogWithWorkspaceBuild} />)
30+
31+
expect(
32+
getByTextContent("TestUser stopped build for workspace test2"),
33+
).toBeDefined()
34+
})
35+
36+
it("renders the correct string for a workspace_build audit log with a duplicate word", async () => {
37+
const AuditLogWithRepeat = {
38+
...MockAuditLogWithWorkspaceBuild,
39+
additional_fields: {
40+
workspaceName: "workspace",
41+
},
42+
}
43+
render(<AuditLogDescription auditLog={AuditLogWithRepeat} />)
44+
45+
expect(
46+
getByTextContent("TestUser stopped build for workspace workspace"),
47+
).toBeDefined()
48+
})
49+
})

0 commit comments

Comments
 (0)