Skip to content

feat: Add audit log filters #4078

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Sep 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions coderd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -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)
Expand Down Expand Up @@ -97,16 +111,27 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
}
}

var params codersdk.CreateTestAuditLogRequest
if !httpapi.Read(rw, r, &params) {
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("{}"),
Expand Down Expand Up @@ -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
}
81 changes: 79 additions & 2 deletions coderd/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
})
}
8 changes: 8 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 23 additions & 3 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions coderd/database/queries/auditlogs.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions codersdk/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/netip"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -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"`
}
Expand All @@ -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, " "))
Comment on lines +119 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly confused with params. It seems like it can only ever have 0 or 1 elements. Why do we need to strings.Join it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I copied and paste from the workspace logic.

Copy link
Collaborator Author

@BrunoQuaresma BrunoQuaresma Sep 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking better, it could have resource_type:workspace action:create right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this where we're defining the name of the param? Do we have control over calling it q or something else?

r.URL.RawQuery = q.Encode()
})
if err != nil {
return AuditLogResponse{}, err
}
Expand Down Expand Up @@ -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
}
Expand Down
20 changes: 13 additions & 7 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,15 +428,21 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
return response.data
}

interface GetAuditLogsOptions {
limit: number
offset: number
}

export const getAuditLogs = async (
options: GetAuditLogsOptions,
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {
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
}

Expand Down
11 changes: 11 additions & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down