mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: Add audit log filters in the API (#4078)
This commit is contained in:
@ -6,6 +6,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -30,9 +32,21 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
dblogs, err := api.Database.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
|
||||||
Offset: int32(page.Offset),
|
Offset: int32(page.Offset),
|
||||||
Limit: int32(page.Limit),
|
Limit: int32(page.Limit),
|
||||||
|
ResourceType: filter.ResourceType,
|
||||||
|
Action: filter.Action,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpapi.InternalServerError(rw, err)
|
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{
|
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Ip: ipNet,
|
Ip: ipNet,
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
ResourceType: database.ResourceTypeUser,
|
ResourceType: database.ResourceType(params.ResourceType),
|
||||||
ResourceID: user.ID,
|
ResourceID: user.ID,
|
||||||
ResourceTarget: user.Username,
|
ResourceTarget: user.Username,
|
||||||
Action: database.AuditActionWrite,
|
Action: database.AuditAction(params.Action),
|
||||||
Diff: diff,
|
Diff: diff,
|
||||||
StatusCode: http.StatusOK,
|
StatusCode: http.StatusOK,
|
||||||
AdditionalFields: []byte("{}"),
|
AdditionalFields: []byte("{}"),
|
||||||
@ -179,3 +204,42 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
|||||||
codersdk.ResourceType(alog.ResourceType).FriendlyString(),
|
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)
|
client := coderdtest.New(t, nil)
|
||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
err := client.CreateTestAuditLog(ctx)
|
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
count, err := client.AuditLogCount(ctx)
|
count, err := client.AuditLogCount(ctx)
|
||||||
require.NoError(t, err)
|
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.NoError(t, err)
|
||||||
|
|
||||||
require.Equal(t, int64(1), count.Count)
|
require.Equal(t, int64(1), count.Count)
|
||||||
require.Len(t, alogs.AuditLogs, 1)
|
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
|
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)
|
user, err := q.GetUserByID(ctx, alog.UserID)
|
||||||
userValid := err == nil
|
userValid := err == nil
|
||||||
|
|
||||||
|
@ -314,6 +314,19 @@ FROM
|
|||||||
audit_logs
|
audit_logs
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
users ON audit_logs.user_id = users.id
|
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
|
ORDER BY
|
||||||
"time" DESC
|
"time" DESC
|
||||||
LIMIT
|
LIMIT
|
||||||
@ -323,8 +336,10 @@ OFFSET
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetAuditLogsOffsetParams struct {
|
type GetAuditLogsOffsetParams struct {
|
||||||
Limit int32 `db:"limit" json:"limit"`
|
Limit int32 `db:"limit" json:"limit"`
|
||||||
Offset int32 `db:"offset" json:"offset"`
|
Offset int32 `db:"offset" json:"offset"`
|
||||||
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
||||||
|
Action string `db:"action" json:"action"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetAuditLogsOffsetRow struct {
|
type GetAuditLogsOffsetRow struct {
|
||||||
@ -354,7 +369,12 @@ type GetAuditLogsOffsetRow struct {
|
|||||||
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
|
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
|
||||||
// ID.
|
// ID.
|
||||||
func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,19 @@ FROM
|
|||||||
audit_logs
|
audit_logs
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
users ON audit_logs.user_id = users.id
|
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
|
ORDER BY
|
||||||
"time" DESC
|
"time" DESC
|
||||||
LIMIT
|
LIMIT
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -93,6 +94,11 @@ type AuditLog struct {
|
|||||||
User *User `json:"user"`
|
User *User `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuditLogsRequest struct {
|
||||||
|
SearchQuery string `json:"q,omitempty"`
|
||||||
|
Pagination
|
||||||
|
}
|
||||||
|
|
||||||
type AuditLogResponse struct {
|
type AuditLogResponse struct {
|
||||||
AuditLogs []AuditLog `json:"audit_logs"`
|
AuditLogs []AuditLog `json:"audit_logs"`
|
||||||
}
|
}
|
||||||
@ -101,9 +107,22 @@ type AuditLogCountResponse struct {
|
|||||||
Count int64 `json:"count"`
|
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.
|
// AuditLogs retrieves audit logs from the given page.
|
||||||
func (c *Client) AuditLogs(ctx context.Context, page Pagination) (AuditLogResponse, error) {
|
func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) {
|
||||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, page.asRequestOption())
|
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 {
|
if err != nil {
|
||||||
return AuditLogResponse{}, err
|
return AuditLogResponse{}, err
|
||||||
}
|
}
|
||||||
@ -143,8 +162,8 @@ func (c *Client) AuditLogCount(ctx context.Context) (AuditLogCountResponse, erro
|
|||||||
return logRes, nil
|
return logRes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CreateTestAuditLog(ctx context.Context) error {
|
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
|
||||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", nil)
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -428,15 +428,21 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetAuditLogsOptions {
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAuditLogs = async (
|
export const getAuditLogs = async (
|
||||||
options: GetAuditLogsOptions,
|
options: TypesGen.AuditLogsRequest,
|
||||||
): Promise<TypesGen.AuditLogResponse> => {
|
): 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
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,6 +86,11 @@ export interface AuditLogResponse {
|
|||||||
readonly audit_logs: AuditLog[]
|
readonly audit_logs: AuditLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From codersdk/audit.go
|
||||||
|
export interface AuditLogsRequest extends Pagination {
|
||||||
|
readonly q?: string
|
||||||
|
}
|
||||||
|
|
||||||
// From codersdk/users.go
|
// From codersdk/users.go
|
||||||
export interface AuthMethods {
|
export interface AuthMethods {
|
||||||
readonly password: boolean
|
readonly password: boolean
|
||||||
@ -166,6 +171,12 @@ export interface CreateTemplateVersionRequest {
|
|||||||
readonly parameter_values?: CreateParameterRequest[]
|
readonly parameter_values?: CreateParameterRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From codersdk/audit.go
|
||||||
|
export interface CreateTestAuditLogRequest {
|
||||||
|
readonly action?: AuditAction
|
||||||
|
readonly resource_type?: ResourceType
|
||||||
|
}
|
||||||
|
|
||||||
// From codersdk/users.go
|
// From codersdk/users.go
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
readonly email: string
|
readonly email: string
|
||||||
|
Reference in New Issue
Block a user