Files
coder/coderd/audit.go
T
Spike Curtis bddb808b25 chore: arrange imports in a standard way (#21452)
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example:

```
import (
	"context"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"golang.org/x/xerrors"
	"gopkg.in/natefinch/lumberjack.v2"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/codersdk/agentsdk"
	"github.com/coder/serpent"
)
```

3 groups: standard library, 3rd partly libs, Coder libs.

This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
2026-01-08 15:24:11 +04:00

522 lines
16 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get audit logs
// @ID get-audit-logs
// @Security CoderSessionToken
// @Produce json
// @Tags Audit
// @Param q query string false "Search query"
// @Param limit query int true "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.AuditLogResponse
// @Router /audit [get]
func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := ParsePagination(rw, r)
if !ok {
return
}
queryStr := r.URL.Query().Get("q")
filter, countFilter, errs := searchquery.AuditLogs(ctx, api.Database, queryStr)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid audit search query.",
Validations: errs,
})
return
}
// #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range
filter.OffsetOpt = int32(page.Offset)
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
filter.LimitOpt = int32(page.Limit)
if filter.Username == "me" {
filter.UserID = apiKey.UserID
filter.Username = ""
countFilter.UserID = apiKey.UserID
countFilter.Username = ""
}
// Use the same filters to count the number of audit logs
count, err := api.Database.CountAuditLogs(ctx, countFilter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// If count is 0, then we don't need to query audit logs
if count == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: []codersdk.AuditLog{},
Count: 0,
})
return
}
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: api.convertAuditLogs(ctx, dblogs),
Count: count,
})
}
// @Summary Generate fake audit log
// @ID generate-fake-audit-log
// @Security CoderSessionToken
// @Accept json
// @Tags Audit
// @Param request body codersdk.CreateTestAuditLogRequest true "Audit log request"
// @Success 204
// @Router /audit/testgenerate [post]
// @x-apidocgen {"skip": true}
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
ip := net.ParseIP(r.RemoteAddr)
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,
}
}
var params codersdk.CreateTestAuditLogRequest
if !httpapi.Read(ctx, rw, r, &params) {
return
}
if params.Action == "" {
params.Action = codersdk.AuditActionWrite
}
if params.ResourceType == "" {
params.ResourceType = codersdk.ResourceTypeUser
}
if params.ResourceID == uuid.Nil {
params.ResourceID = uuid.New()
}
if params.Time.IsZero() {
params.Time = time.Now()
}
if len(params.AdditionalFields) == 0 {
params.AdditionalFields = json.RawMessage("{}")
}
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
ID: uuid.New(),
Time: params.Time,
UserID: user.ID,
Ip: ipNet,
UserAgent: sql.NullString{String: r.UserAgent(), Valid: true},
ResourceType: database.ResourceType(params.ResourceType),
ResourceID: params.ResourceID,
ResourceTarget: user.Username,
Action: database.AuditAction(params.Action),
Diff: diff,
StatusCode: http.StatusOK,
AdditionalFields: params.AdditionalFields,
RequestID: params.RequestID,
ResourceIcon: "",
OrganizationID: params.OrganizationID,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) convertAuditLogs(ctx context.Context, dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
for _, dblog := range dblogs {
alogs = append(alogs, api.convertAuditLog(ctx, dblog))
}
return alogs
}
func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
ip, _ := netip.AddrFromSlice(dblog.AuditLog.Ip.IPNet.IP)
diff := codersdk.AuditDiff{}
_ = json.Unmarshal(dblog.AuditLog.Diff, &diff)
var user *codersdk.User
if dblog.UserUsername.Valid {
// Leaving the organization IDs blank for now; not sure they are useful for
// the audit query anyway?
sdkUser := db2sdk.User(database.User{
ID: dblog.AuditLog.UserID,
Email: dblog.UserEmail.String,
Username: dblog.UserUsername.String,
CreatedAt: dblog.UserCreatedAt.Time,
UpdatedAt: dblog.UserUpdatedAt.Time,
Status: dblog.UserStatus.UserStatus,
RBACRoles: dblog.UserRoles,
LoginType: dblog.UserLoginType.LoginType,
AvatarURL: dblog.UserAvatarUrl.String,
Deleted: dblog.UserDeleted.Bool,
LastSeenAt: dblog.UserLastSeenAt.Time,
QuietHoursSchedule: dblog.UserQuietHoursSchedule.String,
Name: dblog.UserName.String,
}, []uuid.UUID{})
user = &sdkUser
}
var (
additionalFieldsBytes = []byte(dblog.AuditLog.AdditionalFields)
additionalFields audit.AdditionalFields
err = json.Unmarshal(additionalFieldsBytes, &additionalFields)
)
if err != nil {
api.Logger.Error(ctx, "unmarshal additional fields", slog.Error(err))
resourceInfo := audit.AdditionalFields{
WorkspaceName: "unknown",
BuildNumber: "unknown",
BuildReason: "unknown",
WorkspaceOwner: "unknown",
}
dblog.AuditLog.AdditionalFields, err = json.Marshal(resourceInfo)
api.Logger.Error(ctx, "marshal additional fields", slog.Error(err))
}
var (
isDeleted = api.auditLogIsResourceDeleted(ctx, dblog)
resourceLink string
)
if isDeleted {
resourceLink = ""
} else {
resourceLink = api.auditLogResourceLink(ctx, dblog, additionalFields)
}
alog := codersdk.AuditLog{
ID: dblog.AuditLog.ID,
RequestID: dblog.AuditLog.RequestID,
Time: dblog.AuditLog.Time,
// OrganizationID is deprecated.
OrganizationID: dblog.AuditLog.OrganizationID,
IP: ip,
UserAgent: dblog.AuditLog.UserAgent.String,
ResourceType: codersdk.ResourceType(dblog.AuditLog.ResourceType),
ResourceID: dblog.AuditLog.ResourceID,
ResourceTarget: dblog.AuditLog.ResourceTarget,
ResourceIcon: dblog.AuditLog.ResourceIcon,
Action: codersdk.AuditAction(dblog.AuditLog.Action),
Diff: diff,
StatusCode: dblog.AuditLog.StatusCode,
AdditionalFields: dblog.AuditLog.AdditionalFields,
User: user,
Description: auditLogDescription(dblog),
ResourceLink: resourceLink,
IsDeleted: isDeleted,
}
if dblog.AuditLog.OrganizationID != uuid.Nil {
alog.Organization = &codersdk.MinimalOrganization{
ID: dblog.AuditLog.OrganizationID,
Name: dblog.OrganizationName,
DisplayName: dblog.OrganizationDisplayName,
Icon: dblog.OrganizationIcon,
}
}
return alog
}
func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
b := strings.Builder{}
// NOTE: WriteString always returns a nil error, so we never check it
// Requesting a password reset can be performed by anyone that knows the email
// of a user so saying the user performed this action might be slightly misleading.
if alog.AuditLog.Action != database.AuditActionRequestPasswordReset {
_, _ = b.WriteString("{user} ")
}
switch {
case alog.AuditLog.StatusCode == int32(http.StatusSeeOther):
_, _ = b.WriteString("was redirected attempting to ")
_, _ = b.WriteString(string(alog.AuditLog.Action))
case alog.AuditLog.StatusCode >= 400:
_, _ = b.WriteString("unsuccessfully attempted to ")
_, _ = b.WriteString(string(alog.AuditLog.Action))
default:
_, _ = b.WriteString(codersdk.AuditAction(alog.AuditLog.Action).Friendly())
}
// API Key resources (used for authentication) do not have targets and follow the below format:
// "User {logged in | logged out | registered}"
if alog.AuditLog.ResourceType == database.ResourceTypeApiKey &&
(alog.AuditLog.Action == database.AuditActionLogin || alog.AuditLog.Action == database.AuditActionLogout || alog.AuditLog.Action == database.AuditActionRegister) {
return b.String()
}
// We don't display the name (target) for git ssh keys. It's fairly long and doesn't
// make too much sense to display.
if alog.AuditLog.ResourceType == database.ResourceTypeGitSshKey {
_, _ = b.WriteString(" the ")
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
return b.String()
}
if alog.AuditLog.Action == database.AuditActionRequestPasswordReset {
_, _ = b.WriteString(" for")
} else {
_, _ = b.WriteString(" ")
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
}
if alog.AuditLog.ResourceType == database.ResourceTypeConvertLogin {
_, _ = b.WriteString(" to")
}
_, _ = b.WriteString(" {target}")
return b.String()
}
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
switch alog.AuditLog.ResourceType {
case database.ResourceTypeTemplate:
template, err := api.Database.GetTemplateByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch template", slog.Error(err))
}
return template.Deleted
case database.ResourceTypeUser:
user, err := api.Database.GetUserByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch user", slog.Error(err))
}
return user.Deleted
case database.ResourceTypeWorkspace:
workspace, err := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
}
return workspace.Deleted
case database.ResourceTypeWorkspaceBuild:
workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch workspace build", slog.Error(err))
}
// We use workspace as a proxy for workspace build here
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
}
return workspace.Deleted
case database.ResourceTypeWorkspaceAgent:
// We use workspace as a proxy for workspace agents.
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
}
return workspace.Deleted
case database.ResourceTypeWorkspaceApp:
// We use workspace as a proxy for workspace apps.
workspace, err := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
}
return workspace.Deleted
case database.ResourceTypeOauth2ProviderApp:
_, err := api.Database.GetOAuth2ProviderAppByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
} else if err != nil {
api.Logger.Error(ctx, "unable to fetch oauth2 app", slog.Error(err))
}
return false
case database.ResourceTypeOauth2ProviderAppSecret:
_, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
} else if err != nil {
api.Logger.Error(ctx, "unable to fetch oauth2 app secret", slog.Error(err))
}
return false
case database.ResourceTypeTask:
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
} else if err != nil {
api.Logger.Error(ctx, "unable to fetch task", slog.Error(err))
}
return task.DeletedAt.Valid && task.DeletedAt.Time.Before(time.Now())
default:
return false
}
}
func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow, additionalFields audit.AdditionalFields) string {
switch alog.AuditLog.ResourceType {
case database.ResourceTypeTemplate:
return fmt.Sprintf("/templates/%s",
alog.AuditLog.ResourceTarget)
case database.ResourceTypeUser:
return fmt.Sprintf("/users?filter=%s",
alog.AuditLog.ResourceTarget)
case database.ResourceTypeWorkspace:
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
if getWorkspaceErr != nil {
return ""
}
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
if getWorkspaceOwnerErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s",
workspaceOwner.Username, alog.AuditLog.ResourceTarget)
case database.ResourceTypeWorkspaceBuild:
if len(additionalFields.WorkspaceName) == 0 || len(additionalFields.BuildNumber) == 0 {
return ""
}
workspaceBuild, getWorkspaceBuildErr := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID)
if getWorkspaceBuildErr != nil {
return ""
}
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if getWorkspaceErr != nil {
return ""
}
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
if getWorkspaceOwnerErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s/builds/%s",
workspaceOwner.Username, additionalFields.WorkspaceName, additionalFields.BuildNumber)
case database.ResourceTypeWorkspaceAgent:
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
}
workspace, getWorkspaceErr := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeWorkspaceApp:
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
}
workspace, getWorkspaceErr := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeOauth2ProviderApp:
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)
case database.ResourceTypeOauth2ProviderAppSecret:
secret, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
return ""
}
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", secret.AppID)
case database.ResourceTypeTask:
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
return ""
}
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
if err != nil {
return ""
}
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
default:
return ""
}
}