Files
coder/coderd/audit.go
T
Cian Johnston 6969fc7562 fix: improve chat audit log descriptions and diff rendering (#25728)
Chat ACL audit diffs rendered as `[object Object]` because the diff
viewer called `.toString()` on object values. Common chat operations
(archive, share) showed generic "updated chat" descriptions instead of
semantic ones.

Add `chatAuditLogDescription` to derive semantic descriptions from the
audit diff for successful chat writes: "archived/unarchived chat" for
archive toggles, "updated sharing for chat" for ACL-only changes.
Extract diff value formatting into `formatAuditDiffValue`, which renders
object values as deterministic compact JSON with sorted keys, fixing the
`[object Object]` rendering for chat ACLs and any other object-valued
fields. The previous `determineIdPSyncMappingDiff` workaround for IdP
sync mappings was removed because the generic formatting handles it.

Closes CODAGT-513

> Generated by Coder Agents on behalf of @johnstcn

(cherry picked from commit 7ea0eff94e)
2026-06-02 12:23:14 +01:00

627 lines
20 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"
)
// Limit the count query to avoid a slow sequential scan due to joins
// on a large table. Set to 0 to disable capping (but also see the note
// in the SQL query).
const auditLogCountCap = 2000
// @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 /api/v2/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 = ""
}
countFilter.CountCap = auditLogCountCap
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,
CountCap: auditLogCountCap,
})
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,
CountCap: auditLogCountCap,
})
}
// @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 /api/v2/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} ")
}
// Chat write operations get semantic descriptions derived from the diff.
if desc, ok := chatAuditLogDescription(alog); ok {
_, _ = b.WriteString(desc)
return b.String()
}
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()
}
// chatAuditLogDescription returns a description for successful chat write
// operations based on the diff contents. It returns false for non-chat
// resources, non-write actions, or error/redirect status codes, letting
// the caller fall through to the generic description.
func chatAuditLogDescription(alog database.GetAuditLogsOffsetRow) (string, bool) {
if alog.AuditLog.ResourceType != database.ResourceTypeChat ||
alog.AuditLog.Action != database.AuditActionWrite ||
alog.AuditLog.StatusCode >= 400 ||
alog.AuditLog.StatusCode == int32(http.StatusSeeOther) {
return "", false
}
var diff codersdk.AuditDiff
if err := json.Unmarshal(alog.AuditLog.Diff, &diff); err != nil {
return "", false
}
// Single "archived" field: archive or unarchive.
if len(diff) == 1 {
if field, ok := diff["archived"]; ok {
oldVal, oldOK := field.Old.(bool)
newVal, newOK := field.New.(bool)
if oldOK && newOK {
if !oldVal && newVal {
return "archived chat {target}", true
}
if oldVal && !newVal {
return "unarchived chat {target}", true
}
}
}
}
// All fields are ACL changes: sharing update.
if len(diff) > 0 {
aclOnly := true
for field := range diff {
if field != "user_acl" && field != "group_acl" {
aclOnly = false
break
}
}
if aclOnly {
return "updated sharing for chat {target}", true
}
}
return "", false
}
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())
case database.ResourceTypeChat:
// Chats are hard-deleted, so a 404 means deleted.
_, err := api.Database.GetChatByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
if err != nil {
api.Logger.Error(ctx, "unable to fetch chat", slog.Error(err))
}
return false
case database.ResourceTypeUserSecret:
_, err := api.Database.GetUserSecretByID(ctx, alog.AuditLog.ResourceID)
if xerrors.Is(err, sql.ErrNoRows) {
return true
}
// Only users have user_secret:read on their own secrets. If dbauthz returns
// ErrUnauthorized, it's not an error worth logging because we have enough
// information to know it's not deleted.
if err != nil && !dbauthz.IsNotAuthorizedError(err) {
api.Logger.Error(ctx, "unable to fetch user secret", slog.Error(err))
}
return false
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)
case database.ResourceTypeChat:
// Chats are surfaced at /agents/{id}. They are owner-scoped but
// not username-scoped in the URL like workspaces or tasks.
return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID)
case database.ResourceTypeUserSecret:
// TODO(PLAT-102): point at the user secrets management page once
// it ships. Until then, the audit row links nowhere.
return ""
case database.ResourceTypeGroupAiBudget:
// The resource_id is the group's UUID; link to the group's
// settings page.
group, err := api.Database.GetGroupByID(ctx, alog.AuditLog.ResourceID)
if err != nil {
return ""
}
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
return ""
}
return fmt.Sprintf("/organizations/%s/groups/%s", org.Name, group.Name)
default:
return ""
}
}