diff --git a/cli/portforward.go b/cli/portforward.go index ae5daeb954..ac4561163f 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -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" diff --git a/coderd/audit.go b/coderd/audit.go new file mode 100644 index 0000000000..2c86c8ce78 --- /dev/null +++ b/coderd/audit.go @@ -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, + } +} diff --git a/coderd/audit_test.go b/coderd/audit_test.go new file mode 100644 index 0000000000..f2050df935 --- /dev/null +++ b/coderd/audit_test.go @@ -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) + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index b4042197d1..d3eeb7a462 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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, diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index b4cf051fc8..588c96fa8d 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 23cd258f49..2cb7cd4594 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index de1d2d1812..9792595b0f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index cb87ce065e..b7b6c93020 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -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 diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index d2a52511df..4540538ce3 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -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] diff --git a/codersdk/audit.go b/codersdk/audit.go index c9a7296cb1..a1b37ab1b0 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -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 +} diff --git a/enterprise/audit/backends/postgres_test.go b/enterprise/audit/backends/postgres_test.go index ceddbe3958..9efa6e45a2 100644 --- a/enterprise/audit/backends/postgres_test.go +++ b/enterprise/audit/backends/postgres_test.go @@ -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) }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d5d33226c4..21bace379c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -47,10 +47,10 @@ export type AuditDiff = Record // 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