mirror of https://github.com/coder/coder.git
feat: Add audit log filters in the API (#4078)
This commit is contained in:
parent
f314f30ebc
commit
bf8d823ae3
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue