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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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

View File

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

View File

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

View File

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