feat: Add audit log filters in the API (#4078)

This commit is contained in:
Bruno Quaresma
2022-09-19 10:37:33 -03:00
committed by GitHub
parent f314f30ebc
commit bf8d823ae3
8 changed files with 238 additions and 20 deletions

View File

@ -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, &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("{}"),
@ -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
}

View File

@ -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")
})
}
})
}

View File

@ -2361,6 +2361,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

View File

@ -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
}

View File

@ -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