feat: audit log api (#3898)

This commit is contained in:
Colin Adler 2022-09-07 11:38:19 -05:00 committed by GitHub
parent ad24404018
commit 3d6d51fbd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 425 additions and 59 deletions

View File

@ -17,7 +17,6 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/agent"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"

172
coderd/audit.go Normal file
View File

@ -0,0 +1,172 @@
package coderd
import (
"encoding/json"
"net"
"net/http"
"net/netip"
"time"
"github.com/google/uuid"
"github.com/tabbed/pqtype"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
httpapi.Forbidden(rw)
return
}
ctx := r.Context()
page, ok := parsePagination(rw, r)
if !ok {
return
}
dblogs, err := api.Database.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
Offset: int32(page.Offset),
Limit: int32(page.Limit),
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: convertAuditLogs(dblogs),
})
}
func (api *API) auditLogCount(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
httpapi.Forbidden(rw)
return
}
count, err := api.Database.GetAuditLogCount(ctx)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(rw, http.StatusOK, codersdk.AuditLogCountResponse{
Count: count,
})
}
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
httpapi.Forbidden(rw)
return
}
key := httpmw.APIKey(r)
user, err := api.Database.GetUserByID(ctx, key.UserID)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
diff, err := json.Marshal(codersdk.AuditDiff{
"foo": codersdk.AuditDiffField{Old: "bar", New: "baz"},
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
ipRaw, _, _ := net.SplitHostPort(r.RemoteAddr)
ip := net.ParseIP(ipRaw)
ipNet := pqtype.Inet{}
if ip != nil {
ipNet = pqtype.Inet{
IPNet: net.IPNet{
IP: ip,
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
},
Valid: true,
}
}
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
ID: uuid.New(),
Time: time.Now(),
UserID: user.ID,
Ip: ipNet,
UserAgent: r.UserAgent(),
ResourceType: database.ResourceTypeUser,
ResourceID: user.ID,
ResourceTarget: user.Username,
Action: database.AuditActionWrite,
Diff: diff,
StatusCode: http.StatusOK,
AdditionalFields: []byte("{}"),
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
func convertAuditLogs(dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
for _, dblog := range dblogs {
alogs = append(alogs, convertAuditLog(dblog))
}
return alogs
}
func convertAuditLog(dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
ip, _ := netip.AddrFromSlice(dblog.Ip.IPNet.IP)
diff := codersdk.AuditDiff{}
_ = json.Unmarshal(dblog.Diff, &diff)
var user *codersdk.User
if dblog.UserUsername.Valid {
user = &codersdk.User{
ID: dblog.UserID,
Username: dblog.UserUsername.String,
Email: dblog.UserEmail.String,
CreatedAt: dblog.UserCreatedAt.Time,
Status: codersdk.UserStatus(dblog.UserStatus),
Roles: []codersdk.Role{},
}
for _, roleName := range dblog.UserRoles {
rbacRole, _ := rbac.RoleByName(roleName)
user.Roles = append(user.Roles, convertRole(rbacRole))
}
}
return codersdk.AuditLog{
ID: dblog.ID,
RequestID: dblog.RequestID,
Time: dblog.Time,
OrganizationID: dblog.OrganizationID,
IP: ip,
UserAgent: dblog.UserAgent,
ResourceType: codersdk.ResourceType(dblog.ResourceType),
ResourceID: dblog.ResourceID,
ResourceTarget: dblog.ResourceTarget,
ResourceIcon: dblog.ResourceIcon,
Action: codersdk.AuditAction(dblog.Action),
Diff: diff,
StatusCode: dblog.StatusCode,
AdditionalFields: dblog.AdditionalFields,
Description: "",
User: user,
}
}

35
coderd/audit_test.go Normal file
View File

@ -0,0 +1,35 @@
package coderd_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
)
func TestAuditLogs(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
err := client.CreateTestAuditLog(ctx)
require.NoError(t, err)
count, err := client.AuditLogCount(ctx)
require.NoError(t, err)
alogs, err := client.AuditLogs(ctx, codersdk.Pagination{Limit: 1})
require.NoError(t, err)
require.Equal(t, int64(1), count.Count)
require.Len(t, alogs.AuditLogs, 1)
})
}

View File

@ -220,6 +220,15 @@ func New(options *Options) *API {
})
})
})
r.Route("/audit", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.auditLogs)
r.Get("/count", api.auditLogCount)
r.Post("/testgenerate", api.generateFakeAuditLog)
})
r.Route("/files", func(r chi.Router) {
r.Use(
apiKeyMiddleware,

View File

@ -2303,36 +2303,45 @@ func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error
return sql.ErrNoRows
}
func (q *fakeQuerier) GetAuditLogsBefore(_ context.Context, arg database.GetAuditLogsBeforeParams) ([]database.AuditLog, error) {
func (q *fakeQuerier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
logs := make([]database.AuditLog, 0)
start := database.AuditLog{}
if arg.ID != uuid.Nil {
for _, alog := range q.auditLogs {
if alog.ID == arg.ID {
start = alog
break
}
}
} else {
start.ID = uuid.New()
start.Time = arg.StartTime
}
if start.ID == uuid.Nil {
return nil, sql.ErrNoRows
}
logs := make([]database.GetAuditLogsOffsetRow, 0, arg.Limit)
// q.auditLogs are already sorted by time DESC, so no need to sort after the fact.
for _, alog := range q.auditLogs {
if alog.Time.Before(start.Time) {
logs = append(logs, alog)
if arg.Offset > 0 {
arg.Offset--
continue
}
if len(logs) >= int(arg.RowLimit) {
user, err := q.GetUserByID(ctx, alog.UserID)
userValid := err == nil
logs = append(logs, database.GetAuditLogsOffsetRow{
ID: alog.ID,
RequestID: alog.RequestID,
OrganizationID: alog.OrganizationID,
Ip: alog.Ip,
UserAgent: alog.UserAgent,
ResourceType: database.ResourceType(alog.UserAgent),
ResourceID: alog.ResourceID,
ResourceTarget: alog.ResourceTarget,
ResourceIcon: alog.ResourceIcon,
Action: alog.Action,
Diff: alog.Diff,
StatusCode: alog.StatusCode,
AdditionalFields: alog.AdditionalFields,
UserID: alog.UserID,
UserUsername: sql.NullString{String: user.Username, Valid: userValid},
UserEmail: sql.NullString{String: user.Email, Valid: userValid},
UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid},
UserStatus: user.Status,
UserRoles: user.RBACRoles,
})
if len(logs) >= int(arg.Limit) {
break
}
}
@ -2340,6 +2349,13 @@ func (q *fakeQuerier) GetAuditLogsBefore(_ context.Context, arg database.GetAudi
return logs, nil
}
func (q *fakeQuerier) GetAuditLogCount(_ context.Context) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
return int64(len(q.auditLogs)), nil
}
func (q *fakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAuditLogParams) (database.AuditLog, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

View File

@ -27,9 +27,10 @@ type querier interface {
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
GetActiveUserCount(ctx context.Context) (int64, error)
// GetAuditLogsBefore retrieves `limit` number of audit logs before the provided
GetAuditLogCount(ctx context.Context) (int64, error)
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
// ID.
GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error)
GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error)
// This function returns roles for authorization purposes. Implied member roles
// are included.
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)

View File

@ -287,36 +287,79 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP
return err
}
const getAuditLogsBefore = `-- name: GetAuditLogsBefore :many
const getAuditLogCount = `-- name: GetAuditLogCount :one
SELECT
id, time, user_id, organization_id, ip, user_agent, resource_type, resource_id, resource_target, action, diff, status_code, additional_fields, request_id, resource_icon
COUNT(*) as count
FROM
audit_logs
`
func (q *sqlQuerier) GetAuditLogCount(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, getAuditLogCount)
var count int64
err := row.Scan(&count)
return count, err
}
const getAuditLogsOffset = `-- name: GetAuditLogsOffset :many
SELECT
audit_logs.id, audit_logs.time, audit_logs.user_id, audit_logs.organization_id, audit_logs.ip, audit_logs.user_agent, audit_logs.resource_type, audit_logs.resource_id, audit_logs.resource_target, audit_logs.action, audit_logs.diff, audit_logs.status_code, audit_logs.additional_fields, audit_logs.request_id, audit_logs.resource_icon,
users.username AS user_username,
users.email AS user_email,
users.created_at AS user_created_at,
users.status AS user_status,
users.rbac_roles AS user_roles
FROM
audit_logs
WHERE
audit_logs."time" < COALESCE((SELECT "time" FROM audit_logs a WHERE a.id = $1), $2)
LEFT JOIN
users ON audit_logs.user_id = users.id
ORDER BY
"time" DESC
LIMIT
$3
$1
OFFSET
$2
`
type GetAuditLogsBeforeParams struct {
ID uuid.UUID `db:"id" json:"id"`
StartTime time.Time `db:"start_time" json:"start_time"`
RowLimit int32 `db:"row_limit" json:"row_limit"`
type GetAuditLogsOffsetParams struct {
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
}
// GetAuditLogsBefore retrieves `limit` number of audit logs before the provided
type GetAuditLogsOffsetRow struct {
ID uuid.UUID `db:"id" json:"id"`
Time time.Time `db:"time" json:"time"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Ip pqtype.Inet `db:"ip" json:"ip"`
UserAgent string `db:"user_agent" json:"user_agent"`
ResourceType ResourceType `db:"resource_type" json:"resource_type"`
ResourceID uuid.UUID `db:"resource_id" json:"resource_id"`
ResourceTarget string `db:"resource_target" json:"resource_target"`
Action AuditAction `db:"action" json:"action"`
Diff json.RawMessage `db:"diff" json:"diff"`
StatusCode int32 `db:"status_code" json:"status_code"`
AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"`
RequestID uuid.UUID `db:"request_id" json:"request_id"`
ResourceIcon string `db:"resource_icon" json:"resource_icon"`
UserUsername sql.NullString `db:"user_username" json:"user_username"`
UserEmail sql.NullString `db:"user_email" json:"user_email"`
UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"`
UserStatus UserStatus `db:"user_status" json:"user_status"`
UserRoles []string `db:"user_roles" json:"user_roles"`
}
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
// ID.
func (q *sqlQuerier) GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error) {
rows, err := q.db.QueryContext(ctx, getAuditLogsBefore, arg.ID, arg.StartTime, arg.RowLimit)
func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error) {
rows, err := q.db.QueryContext(ctx, getAuditLogsOffset, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
var items []GetAuditLogsOffsetRow
for rows.Next() {
var i AuditLog
var i GetAuditLogsOffsetRow
if err := rows.Scan(
&i.ID,
&i.Time,
@ -333,6 +376,11 @@ func (q *sqlQuerier) GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBef
&i.AdditionalFields,
&i.RequestID,
&i.ResourceIcon,
&i.UserUsername,
&i.UserEmail,
&i.UserCreatedAt,
&i.UserStatus,
pq.Array(&i.UserRoles),
); err != nil {
return nil, err
}

View File

@ -1,16 +1,29 @@
-- GetAuditLogsBefore retrieves `limit` number of audit logs before the provided
-- GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
-- ID.
-- name: GetAuditLogsBefore :many
-- name: GetAuditLogsOffset :many
SELECT
*
audit_logs.*,
users.username AS user_username,
users.email AS user_email,
users.created_at AS user_created_at,
users.status AS user_status,
users.rbac_roles AS user_roles
FROM
audit_logs
WHERE
audit_logs."time" < COALESCE((SELECT "time" FROM audit_logs a WHERE a.id = sqlc.arg(id)), sqlc.arg(start_time))
LEFT JOIN
users ON audit_logs.user_id = users.id
ORDER BY
"time" DESC
LIMIT
sqlc.arg(row_limit);
$1
OFFSET
$2;
-- name: GetAuditLogCount :one
SELECT
COUNT(*) as count
FROM
audit_logs;
-- name: InsertAuditLog :one
INSERT INTO

View File

@ -247,7 +247,7 @@ func CanAssignRole(roles []string, assignedRole string) bool {
func RoleByName(name string) (Role, error) {
roleName, orgID, err := roleSplit(name)
if err != nil {
return Role{}, xerrors.Errorf(":%w", err)
return Role{}, xerrors.Errorf("parse role name: %w", err)
}
roleFunc, ok := builtInRoles[roleName]

View File

@ -1,7 +1,9 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"net/netip"
"time"
@ -29,9 +31,9 @@ const (
type AuditDiff map[string]AuditDiffField
type AuditDiffField struct {
Old any
New any
Secret bool
Old any `json:"old,omitempty"`
New any `json:"new,omitempty"`
Secret bool `json:"secret"`
}
type AuditLog struct {
@ -54,3 +56,67 @@ type AuditLog struct {
User *User `json:"user"`
}
type AuditLogResponse struct {
AuditLogs []AuditLog `json:"audit_logs"`
}
type AuditLogCountResponse struct {
Count int64 `json:"count"`
}
// 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())
if err != nil {
return AuditLogResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuditLogResponse{}, readBodyAsError(res)
}
var logRes AuditLogResponse
err = json.NewDecoder(res.Body).Decode(&logRes)
if err != nil {
return AuditLogResponse{}, err
}
return logRes, nil
}
// AuditLogCount returns the count of all audit logs in the product.
func (c *Client) AuditLogCount(ctx context.Context) (AuditLogCountResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit/count", nil)
if err != nil {
return AuditLogCountResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuditLogCountResponse{}, readBodyAsError(res)
}
var logRes AuditLogCountResponse
err = json.NewDecoder(res.Body).Decode(&logRes)
if err != nil {
return AuditLogCountResponse{}, err
}
return logRes, nil
}
func (c *Client) CreateTestAuditLog(ctx context.Context) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return err
}
return nil
}

View File

@ -3,9 +3,7 @@ package backends_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
@ -30,13 +28,12 @@ func TestPostgresBackend(t *testing.T) {
err := pgb.Export(ctx, alog)
require.NoError(t, err)
got, err := db.GetAuditLogsBefore(ctx, database.GetAuditLogsBeforeParams{
ID: uuid.Nil,
StartTime: time.Now().Add(time.Second),
RowLimit: 1,
got, err := db.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
Offset: 0,
Limit: 1,
})
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, alog, got[0])
require.Equal(t, alog.ID, got[0].ID)
})
}

View File

@ -47,10 +47,10 @@ export type AuditDiff = Record<string, AuditDiffField>
// From codersdk/audit.go
export interface AuditDiffField {
// eslint-disable-next-line
readonly Old: any
readonly old?: any
// eslint-disable-next-line
readonly New: any
readonly Secret: boolean
readonly new?: any
readonly secret: boolean
}
// From codersdk/audit.go
@ -76,6 +76,16 @@ export interface AuditLog {
readonly user?: User
}
// From codersdk/audit.go
export interface AuditLogCountResponse {
readonly count: number
}
// From codersdk/audit.go
export interface AuditLogResponse {
readonly audit_logs: AuditLog[]
}
// From codersdk/users.go
export interface AuthMethods {
readonly password: boolean