diff --git a/coderd/audit.go b/coderd/audit.go index 9fccb49ddfcd9..3b7d18f7b6496 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -6,6 +6,8 @@ import ( "net" "net/http" "net/netip" + "net/url" + "strings" "time" "github.com/google/uuid" @@ -30,9 +32,21 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) { return } + queryStr := r.URL.Query().Get("q") + filter, errs := auditSearchQuery(queryStr) + if len(errs) > 0 { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid audit search query.", + Validations: errs, + }) + return + } + dblogs, err := api.Database.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{ - Offset: int32(page.Offset), - Limit: int32(page.Limit), + Offset: int32(page.Offset), + Limit: int32(page.Limit), + ResourceType: filter.ResourceType, + Action: filter.Action, }) if err != nil { httpapi.InternalServerError(rw, err) @@ -97,16 +111,27 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) { } } + var params codersdk.CreateTestAuditLogRequest + if !httpapi.Read(rw, r, ¶ms) { + return + } + if params.Action == "" { + params.Action = codersdk.AuditActionWrite + } + if params.ResourceType == "" { + params.ResourceType = codersdk.ResourceTypeUser + } + _, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{ ID: uuid.New(), Time: time.Now(), UserID: user.ID, Ip: ipNet, UserAgent: r.UserAgent(), - ResourceType: database.ResourceTypeUser, + ResourceType: database.ResourceType(params.ResourceType), ResourceID: user.ID, ResourceTarget: user.Username, - Action: database.AuditActionWrite, + Action: database.AuditAction(params.Action), Diff: diff, StatusCode: http.StatusOK, AdditionalFields: []byte("{}"), @@ -179,3 +204,42 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { codersdk.ResourceType(alog.ResourceType).FriendlyString(), ) } + +// auditSearchQuery takes a query string and returns the auditLog filter. +// It also can return the list of validation errors to return to the api. +func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) { + searchParams := make(url.Values) + if query == "" { + // No filter + return database.GetAuditLogsOffsetParams{}, nil + } + query = strings.ToLower(query) + // Because we do this in 2 passes, we want to maintain quotes on the first + // pass.Further splitting occurs on the second pass and quotes will be + // dropped. + elements := splitQueryParameterByDelimiter(query, ' ', true) + for _, element := range elements { + parts := splitQueryParameterByDelimiter(element, ':', false) + switch len(parts) { + case 1: + // No key:value pair. + searchParams.Set("resource_type", parts[0]) + case 2: + searchParams.Set(parts[0], parts[1]) + default: + return database.GetAuditLogsOffsetParams{}, []codersdk.ValidationError{ + {Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)}, + } + } + } + + // Using the query param parser here just returns consistent errors with + // other parsing. + parser := httpapi.NewQueryParamParser() + filter := database.GetAuditLogsOffsetParams{ + ResourceType: parser.String(searchParams, "", "resource_type"), + Action: parser.String(searchParams, "", "action"), + } + + return filter, parser.Errors +} diff --git a/coderd/audit_test.go b/coderd/audit_test.go index f2050df93585f..47b7daf9a425f 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -20,16 +20,93 @@ func TestAuditLogs(t *testing.T) { client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - err := client.CreateTestAuditLog(ctx) + err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{}) require.NoError(t, err) count, err := client.AuditLogCount(ctx) require.NoError(t, err) - alogs, err := client.AuditLogs(ctx, codersdk.Pagination{Limit: 1}) + alogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) require.NoError(t, err) require.Equal(t, int64(1), count.Count) require.Len(t, alogs.AuditLogs, 1) }) } + +func TestAuditLogsFilter(t *testing.T) { + t.Parallel() + + t.Run("FilterByResourceType", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // Create two logs with "Create" + err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + Action: codersdk.AuditActionCreate, + ResourceType: codersdk.ResourceTypeTemplate, + }) + require.NoError(t, err) + err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + Action: codersdk.AuditActionCreate, + ResourceType: codersdk.ResourceTypeUser, + }) + require.NoError(t, err) + + // Create one log with "Delete" + err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + Action: codersdk.AuditActionDelete, + ResourceType: codersdk.ResourceTypeUser, + }) + require.NoError(t, err) + + // Test cases + testCases := []struct { + Name string + SearchQuery string + ExpectedResult int + }{ + { + Name: "FilterByCreateAction", + SearchQuery: "action:create", + ExpectedResult: 2, + }, + { + Name: "FilterByDeleteAction", + SearchQuery: "action:delete", + ExpectedResult: 1, + }, + { + Name: "FilterByUserResourceType", + SearchQuery: "resource_type:user", + ExpectedResult: 2, + }, + { + Name: "FilterByTemplateResourceType", + SearchQuery: "resource_type:template", + ExpectedResult: 1, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + auditLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + SearchQuery: testCase.SearchQuery, + Pagination: codersdk.Pagination{ + Limit: 25, + }, + }) + require.NoError(t, err, "fetch audit logs") + require.Len(t, auditLogs.AuditLogs, testCase.ExpectedResult, "expected audit logs returned") + }) + } + }) +} diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 76c0d278b8abe..996c4c5a99ff7 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2337,6 +2337,14 @@ func (q *fakeQuerier) GetAuditLogsOffset(ctx context.Context, arg database.GetAu continue } + if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) { + continue + } + + if arg.ResourceType != "" && !strings.Contains(string(alog.ResourceType), arg.ResourceType) { + continue + } + user, err := q.GetUserByID(ctx, alog.UserID) userValid := err == nil diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 88dd2091a7718..d18a0c57b031d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -314,6 +314,19 @@ FROM audit_logs LEFT JOIN users ON audit_logs.user_id = users.id +WHERE + -- Filter resource_type + CASE + WHEN $3 :: text != '' THEN + resource_type = $3 :: resource_type + ELSE true + END + -- Filter action + AND CASE + WHEN $4 :: text != '' THEN + action = $4 :: audit_action + ELSE true + END ORDER BY "time" DESC LIMIT @@ -323,8 +336,10 @@ OFFSET ` type GetAuditLogsOffsetParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` + ResourceType string `db:"resource_type" json:"resource_type"` + Action string `db:"action" json:"action"` } type GetAuditLogsOffsetRow struct { @@ -354,7 +369,12 @@ type GetAuditLogsOffsetRow struct { // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided // ID. func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error) { - rows, err := q.db.QueryContext(ctx, getAuditLogsOffset, arg.Limit, arg.Offset) + rows, err := q.db.QueryContext(ctx, getAuditLogsOffset, + arg.Limit, + arg.Offset, + arg.ResourceType, + arg.Action, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index 0c2364f828b85..3e29c74de4947 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -13,6 +13,19 @@ FROM audit_logs LEFT JOIN users ON audit_logs.user_id = users.id +WHERE + -- Filter resource_type + CASE + WHEN @resource_type :: text != '' THEN + resource_type = @resource_type :: resource_type + ELSE true + END + -- Filter action + AND CASE + WHEN @action :: text != '' THEN + action = @action :: audit_action + ELSE true + END ORDER BY "time" DESC LIMIT diff --git a/codersdk/audit.go b/codersdk/audit.go index fd26fe58ec0e9..810cf72c2f608 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/netip" + "strings" "time" "github.com/google/uuid" @@ -93,6 +94,11 @@ type AuditLog struct { User *User `json:"user"` } +type AuditLogsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + type AuditLogResponse struct { AuditLogs []AuditLog `json:"audit_logs"` } @@ -101,9 +107,22 @@ type AuditLogCountResponse struct { Count int64 `json:"count"` } +type CreateTestAuditLogRequest struct { + Action AuditAction `json:"action,omitempty"` + ResourceType ResourceType `json:"resource_type,omitempty"` +} + // AuditLogs retrieves audit logs from the given page. -func (c *Client) AuditLogs(ctx context.Context, page Pagination) (AuditLogResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, page.asRequestOption()) +func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, req.Pagination.asRequestOption(), func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + }) if err != nil { return AuditLogResponse{}, err } @@ -143,8 +162,8 @@ func (c *Client) AuditLogCount(ctx context.Context) (AuditLogCountResponse, erro return logRes, nil } -func (c *Client) CreateTestAuditLog(ctx context.Context) error { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", nil) +func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req) if err != nil { return err } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3e2ea86c16d8b..7d4520c11a2a2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -428,15 +428,21 @@ export const getEntitlements = async (): Promise => { return response.data } -interface GetAuditLogsOptions { - limit: number - offset: number -} - export const getAuditLogs = async ( - options: GetAuditLogsOptions, + options: TypesGen.AuditLogsRequest, ): Promise => { - const response = await axios.get(`/api/v2/audit?limit=${options.limit}&offset=${options.offset}`) + const searchParams = new URLSearchParams() + if (options.limit) { + searchParams.set("limit", options.limit.toString()) + } + if (options.offset) { + searchParams.set("offset", options.offset.toString()) + } + if (options.q) { + searchParams.set("q", options.q) + } + + const response = await axios.get(`/api/v2/audit?${searchParams.toString()}`) return response.data } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c5a90816563a4..98a4591ac74dc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -86,6 +86,11 @@ export interface AuditLogResponse { readonly audit_logs: AuditLog[] } +// From codersdk/audit.go +export interface AuditLogsRequest extends Pagination { + readonly q?: string +} + // From codersdk/users.go export interface AuthMethods { readonly password: boolean @@ -166,6 +171,12 @@ export interface CreateTemplateVersionRequest { readonly parameter_values?: CreateParameterRequest[] } +// From codersdk/audit.go +export interface CreateTestAuditLogRequest { + readonly action?: AuditAction + readonly resource_type?: ResourceType +} + // From codersdk/users.go export interface CreateUserRequest { readonly email: string