mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:11:55 +08:00
Adds enterprise audit logging for MCP server config create, update, and
delete, with strict secret redaction. MCP configs hold credentials
(OAuth2 client secrets, API keys, custom headers), so admin changes to
them need an audit trail.
## Summary
- `enterprise/audit/table.go` gains an `MCPServerConfig` entry
enumerating every column: `oauth2_client_secret`, `api_key_value`, and
`custom_headers` are `ActionSecret` (never appear in diffs); dbcrypt
`*_key_id` bookkeeping, IDs, and timestamps are ignored; the remaining
config fields, including the endpoint URL fields, are tracked so
auditors can see which endpoints a config points at.
- Type registration in `coderd/audit` (diff, request, resource target
with org attribution), `codersdk/audit.go`, and a `resource_type` enum
migration.
- Handlers wire `audit.InitRequest`: create records `New`; update and
delete record `Old` from the param middleware before the
write-authorization check, so a readable-but-not-writable caller
produces an audited 403 while read-denied callers stay concealed as
unaudited 404s.
- Tests: create/update/delete audit entries, write-denied and
delete-denied 403 auditing, cross-org concealment producing zero
entries, and a serializer-level regression test proving none of the
three secret classes can reach a serialized diff.
- Review round: MCP config audit entries link to
`/ai/settings/mcp-servers/{id}`, audit table comments are trimmed per
review, and a fault-injection test pins that a config row surviving a
failed post-discovery credential update still gets its creation audit
entry.
Stacked on #27942 (org-scoped MCP configs). Part of the MCP
org-separation stack.
Closes https://linear.app/codercom/issue/CODAGT-717
UAT: verified on a trial-licensed dogfood instance: audit entries for
the full CRUD lifecycle with correct actor/org/target, redacted secrets
in the update and OAuth2 create diffs, and a full plaintext scan of the
audit dump finding zero secret leaks.
> Mux (AI agent) authored this PR on Mike's behalf.
<!-- mux-attribution: model=claude-fable-5 thinking=high -->
710 lines
21 KiB
Go
710 lines
21 KiB
Go
package audit
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel/baggage"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/idpsync"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
)
|
|
|
|
type RequestParams struct {
|
|
Audit Auditor
|
|
Log slog.Logger
|
|
|
|
// OrganizationID is only provided when possible. If an audit resource extends
|
|
// beyond the org scope, leave this as the nil uuid.
|
|
OrganizationID uuid.UUID
|
|
Request *http.Request
|
|
Action database.AuditAction
|
|
AdditionalFields interface{}
|
|
}
|
|
|
|
type Request[T Auditable] struct {
|
|
params *RequestParams
|
|
|
|
Old T
|
|
New T
|
|
|
|
// UserID is an optional field can be passed in when the userID cannot be
|
|
// determined from the API Key such as in the case of login, when the audit
|
|
// log is created prior the API Key's existence.
|
|
UserID uuid.UUID
|
|
|
|
// Action is an optional field can be passed in if the AuditAction must be
|
|
// overridden such as in the case of new user authentication when the Audit
|
|
// Action is 'register', not 'login'.
|
|
Action database.AuditAction
|
|
}
|
|
|
|
// UpdateOrganizationID can be used if the organization ID is not known
|
|
// at the initiation of an audit log request.
|
|
func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
|
|
r.params.OrganizationID = id
|
|
}
|
|
|
|
type BackgroundAuditParams[T Auditable] struct {
|
|
Audit Auditor
|
|
Log slog.Logger
|
|
|
|
UserID uuid.UUID
|
|
RequestID uuid.UUID
|
|
Time time.Time
|
|
Status int
|
|
Action database.AuditAction
|
|
OrganizationID uuid.UUID
|
|
IP string
|
|
UserAgent string
|
|
// todo: this should automatically marshal an interface{} instead of accepting a raw message.
|
|
AdditionalFields json.RawMessage
|
|
|
|
New T
|
|
Old T
|
|
}
|
|
|
|
func ResourceTarget[T Auditable](tgt T) string {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return typed.Name
|
|
case database.TemplateVersion:
|
|
return typed.Name
|
|
case database.User:
|
|
return typed.Username
|
|
case database.WorkspaceTable:
|
|
return typed.Name
|
|
case database.WorkspaceBuild:
|
|
// this isn't used
|
|
return ""
|
|
case database.GitSSHKey:
|
|
return typed.PublicKey
|
|
case database.AuditableGroup:
|
|
return typed.Group.Name
|
|
case database.APIKey:
|
|
if typed.TokenName != "nil" {
|
|
return typed.TokenName
|
|
}
|
|
// API Keys without names are used for auth
|
|
// and don't have a target
|
|
return ""
|
|
case database.License:
|
|
return strconv.Itoa(int(typed.ID))
|
|
case database.WorkspaceProxy:
|
|
return typed.Name
|
|
case database.AuditOAuthConvertState:
|
|
return string(typed.ToLoginType)
|
|
case database.HealthSettings:
|
|
return "" // no target?
|
|
case database.NotificationsSettings:
|
|
return "" // no target?
|
|
case database.PrebuildsSettings:
|
|
return "" // no target?
|
|
case database.OAuth2ProviderSettings:
|
|
return "" // no target?
|
|
case database.OAuth2ProviderApp:
|
|
return typed.Name
|
|
case database.OAuth2ProviderAppSecret:
|
|
return typed.DisplaySecret
|
|
case database.CustomRole:
|
|
return typed.Name
|
|
case database.AuditableOrganizationMember:
|
|
return typed.Username
|
|
case database.Organization:
|
|
return typed.Name
|
|
case database.NotificationTemplate:
|
|
return typed.Name
|
|
case idpsync.OrganizationSyncSettings:
|
|
return "Organization Sync"
|
|
case idpsync.GroupSyncSettings:
|
|
return "Organization Group Sync"
|
|
case idpsync.RoleSyncSettings:
|
|
return "Organization Role Sync"
|
|
case database.TaskTable:
|
|
return typed.Name
|
|
case database.AISeatState:
|
|
return "AI Seat"
|
|
case database.AIProvider:
|
|
return typed.Name
|
|
case database.AIProviderKey:
|
|
return typed.ID.String()
|
|
case database.AIGatewayKey:
|
|
return typed.Name
|
|
case database.AuditableGroupAIBudget:
|
|
return typed.GroupName
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return typed.Username
|
|
case database.Chat:
|
|
// Chat titles can contain sensitive content (secrets, internal
|
|
// project names), so we use a short UUID prefix as a display
|
|
// hint instead. The full UUID is still recorded in resource_id,
|
|
// which is what the audit UI links on. An 8-char prefix is fine
|
|
// for display; collisions affect the display label and search
|
|
// filter but not the primary resource identifier.
|
|
return typed.ID.String()[:8]
|
|
case database.MCPServerConfig:
|
|
// Updates can persist an empty display name; fall back to the slug, or
|
|
// the ID if both are empty, so the audit entry stays identifiable.
|
|
return cmp.Or(typed.DisplayName, typed.Slug, typed.ID.String())
|
|
case database.UserSecret:
|
|
return typed.Name
|
|
case database.UserSkill:
|
|
return typed.Name
|
|
case database.ChatInstructionSettings:
|
|
return typed.Name
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
|
|
}
|
|
}
|
|
|
|
// noID can be used for resources that do not have an uuid.
|
|
// An example is singleton configuration resources.
|
|
// 51A51C = "Static"
|
|
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
|
|
|
|
// Fixed IDs for the two chat instruction settings. History-by-setting works
|
|
// only if every change to one setting carries the same resource ID, so
|
|
// unlike the per-write artificial IDs of the other settings singletons,
|
|
// these never change. C1A7 = "Chat".
|
|
var (
|
|
ChatInstructionSystemPromptID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000001")
|
|
ChatInstructionPlanModeID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000002")
|
|
)
|
|
|
|
// Human-readable targets for the two chat instruction settings, so an audit
|
|
// row names the setting it concerns.
|
|
const (
|
|
ChatInstructionSystemPromptName = "System prompt"
|
|
ChatInstructionPlanModeName = "Plan mode instructions"
|
|
)
|
|
|
|
func ResourceID[T Auditable](tgt T) uuid.UUID {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return typed.ID
|
|
case database.TemplateVersion:
|
|
return typed.ID
|
|
case database.User:
|
|
return typed.ID
|
|
case database.WorkspaceTable:
|
|
return typed.ID
|
|
case database.WorkspaceBuild:
|
|
return typed.ID
|
|
case database.GitSSHKey:
|
|
return typed.UserID
|
|
case database.AuditableGroup:
|
|
return typed.Group.ID
|
|
case database.APIKey:
|
|
return typed.UserID
|
|
case database.License:
|
|
return typed.UUID
|
|
case database.WorkspaceProxy:
|
|
return typed.ID
|
|
case database.AuditOAuthConvertState:
|
|
// The merge state is for the given user
|
|
return typed.UserID
|
|
case database.HealthSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.NotificationsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.PrebuildsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.OAuth2ProviderSettings:
|
|
// Artificial ID for auditing purposes
|
|
return typed.ID
|
|
case database.OAuth2ProviderApp:
|
|
return typed.ID
|
|
case database.OAuth2ProviderAppSecret:
|
|
return typed.ID
|
|
case database.CustomRole:
|
|
return typed.ID
|
|
case database.AuditableOrganizationMember:
|
|
return typed.UserID
|
|
case database.Organization:
|
|
return typed.ID
|
|
case database.NotificationTemplate:
|
|
return typed.ID
|
|
case idpsync.OrganizationSyncSettings:
|
|
return noID // Deployment all uses the same org sync settings
|
|
case idpsync.GroupSyncSettings:
|
|
return noID // Org field on audit log has org id
|
|
case idpsync.RoleSyncSettings:
|
|
return noID // Org field on audit log has org id
|
|
case database.TaskTable:
|
|
return typed.ID
|
|
case database.AISeatState:
|
|
return typed.UserID
|
|
case database.AIProvider:
|
|
return typed.ID
|
|
case database.AIProviderKey:
|
|
return typed.ID
|
|
case database.AIGatewayKey:
|
|
return typed.ID
|
|
case database.AuditableGroupAIBudget:
|
|
return typed.GroupID
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return typed.UserID
|
|
case database.Chat:
|
|
return typed.ID
|
|
case database.MCPServerConfig:
|
|
return typed.ID
|
|
case database.UserSecret:
|
|
return typed.ID
|
|
case database.UserSkill:
|
|
return typed.ID
|
|
case database.ChatInstructionSettings:
|
|
// Fixed ID per setting; see ChatInstructionSettings IDs.
|
|
return typed.ID
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
|
|
}
|
|
}
|
|
|
|
func ResourceType[T Auditable](tgt T) database.ResourceType {
|
|
switch typed := any(tgt).(type) {
|
|
case database.Template:
|
|
return database.ResourceTypeTemplate
|
|
case database.TemplateVersion:
|
|
return database.ResourceTypeTemplateVersion
|
|
case database.User:
|
|
return database.ResourceTypeUser
|
|
case database.WorkspaceTable:
|
|
return database.ResourceTypeWorkspace
|
|
case database.WorkspaceBuild:
|
|
return database.ResourceTypeWorkspaceBuild
|
|
case database.GitSSHKey:
|
|
return database.ResourceTypeGitSshKey
|
|
case database.AuditableGroup:
|
|
return database.ResourceTypeGroup
|
|
case database.APIKey:
|
|
return database.ResourceTypeApiKey
|
|
case database.License:
|
|
return database.ResourceTypeLicense
|
|
case database.WorkspaceProxy:
|
|
return database.ResourceTypeWorkspaceProxy
|
|
case database.AuditOAuthConvertState:
|
|
return database.ResourceTypeConvertLogin
|
|
case database.HealthSettings:
|
|
return database.ResourceTypeHealthSettings
|
|
case database.NotificationsSettings:
|
|
return database.ResourceTypeNotificationsSettings
|
|
case database.PrebuildsSettings:
|
|
return database.ResourceTypePrebuildsSettings
|
|
case database.OAuth2ProviderSettings:
|
|
return database.ResourceTypeOauth2ProviderSettings
|
|
case database.OAuth2ProviderApp:
|
|
return database.ResourceTypeOauth2ProviderApp
|
|
case database.OAuth2ProviderAppSecret:
|
|
return database.ResourceTypeOauth2ProviderAppSecret
|
|
case database.CustomRole:
|
|
return database.ResourceTypeCustomRole
|
|
case database.AuditableOrganizationMember:
|
|
return database.ResourceTypeOrganizationMember
|
|
case database.Organization:
|
|
return database.ResourceTypeOrganization
|
|
case database.NotificationTemplate:
|
|
return database.ResourceTypeNotificationTemplate
|
|
case idpsync.OrganizationSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsOrganization
|
|
case idpsync.RoleSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsRole
|
|
case idpsync.GroupSyncSettings:
|
|
return database.ResourceTypeIdpSyncSettingsGroup
|
|
case database.TaskTable:
|
|
return database.ResourceTypeTask
|
|
case database.AISeatState:
|
|
return database.ResourceTypeAISeat
|
|
case database.AIProvider:
|
|
return database.ResourceTypeAIProvider
|
|
case database.AIProviderKey:
|
|
return database.ResourceTypeAIProviderKey
|
|
case database.AIGatewayKey:
|
|
return database.ResourceTypeAIGatewayKey
|
|
case database.AuditableGroupAIBudget:
|
|
return database.ResourceTypeGroupAIBudget
|
|
case database.AuditableUserAIBudgetOverride:
|
|
return database.ResourceTypeUserAIBudgetOverride
|
|
case database.Chat:
|
|
return database.ResourceTypeChat
|
|
case database.MCPServerConfig:
|
|
return database.ResourceTypeMCPServerConfig
|
|
case database.UserSecret:
|
|
return database.ResourceTypeUserSecret
|
|
case database.UserSkill:
|
|
return database.ResourceTypeUserSkill
|
|
case database.ChatInstructionSettings:
|
|
return database.ResourceTypeChatInstructionSettings
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
|
|
}
|
|
}
|
|
|
|
// ResourceRequiresOrgID will ensure given resources are always audited with an
|
|
// organization ID.
|
|
func ResourceRequiresOrgID[T Auditable]() bool {
|
|
var tgt T
|
|
switch any(tgt).(type) {
|
|
case database.Template, database.TemplateVersion:
|
|
return true
|
|
case database.WorkspaceTable, database.WorkspaceBuild:
|
|
return true
|
|
case database.AuditableGroup:
|
|
return true
|
|
case database.User:
|
|
return false
|
|
case database.GitSSHKey:
|
|
return false
|
|
case database.APIKey:
|
|
return false
|
|
case database.License:
|
|
return false
|
|
case database.WorkspaceProxy:
|
|
return false
|
|
case database.AuditOAuthConvertState:
|
|
// The merge state is for the given user
|
|
return false
|
|
case database.HealthSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.NotificationsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.PrebuildsSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.OAuth2ProviderSettings:
|
|
// Artificial ID for auditing purposes
|
|
return false
|
|
case database.OAuth2ProviderApp:
|
|
return false
|
|
case database.OAuth2ProviderAppSecret:
|
|
return false
|
|
case database.CustomRole:
|
|
return true
|
|
case database.AuditableOrganizationMember:
|
|
return true
|
|
case database.Organization:
|
|
return true
|
|
case database.NotificationTemplate:
|
|
return false
|
|
case idpsync.OrganizationSyncSettings:
|
|
return false
|
|
case idpsync.GroupSyncSettings:
|
|
return true
|
|
case idpsync.RoleSyncSettings:
|
|
return true
|
|
case database.TaskTable:
|
|
return true
|
|
case database.AISeatState:
|
|
return false
|
|
case database.AIProvider:
|
|
// AI providers are deployment-scoped, not org-scoped.
|
|
return false
|
|
case database.AIProviderKey:
|
|
// AI provider keys inherit the deployment scope of their parent
|
|
// provider.
|
|
return false
|
|
case database.AIGatewayKey:
|
|
// AI Gateway keys are deployment-scoped, not org-scoped.
|
|
return false
|
|
case database.AuditableGroupAIBudget:
|
|
// Group AI budgets are org-scoped through their parent group.
|
|
return true
|
|
case database.AuditableUserAIBudgetOverride:
|
|
// User AI budget overrides are org-scoped through their
|
|
// attributed group.
|
|
return true
|
|
case database.Chat:
|
|
// Chats always have a non-null organization_id (since
|
|
// migration 000467).
|
|
return true
|
|
case database.MCPServerConfig:
|
|
// MCP server configs always carry a non-null organization_id.
|
|
return true
|
|
case database.UserSecret:
|
|
// User secrets are global to the user across organizations.
|
|
return false
|
|
case database.UserSkill:
|
|
// User skills are global to the user across organizations.
|
|
return false
|
|
case database.ChatInstructionSettings:
|
|
// Deployment settings, not scoped to any organization.
|
|
return false
|
|
default:
|
|
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
|
|
}
|
|
}
|
|
|
|
// requireOrgID will either panic (in unit tests) or log an error (in production)
|
|
// if the given resource requires an organization ID and the provided ID is nil.
|
|
func requireOrgID[T Auditable](ctx context.Context, id uuid.UUID, log slog.Logger) uuid.UUID {
|
|
if ResourceRequiresOrgID[T]() && id == uuid.Nil {
|
|
var tgt T
|
|
resourceName := fmt.Sprintf("%T", tgt)
|
|
if flag.Lookup("test.v") != nil {
|
|
// In unit tests we panic to fail the tests
|
|
panic(fmt.Sprintf("missing required organization ID for resource %q", resourceName))
|
|
}
|
|
log.Error(ctx, "missing required organization ID for resource in audit log",
|
|
slog.F("resource", resourceName),
|
|
)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// InitRequestWithCancel returns a commit function with a boolean arg.
|
|
// If the arg is false, future calls to commit() will not create an audit log
|
|
// entry.
|
|
func InitRequestWithCancel[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func(commit bool)) {
|
|
req, commitF := InitRequest[T](w, p)
|
|
canceled := false
|
|
return req, func(commit bool) {
|
|
// Once 'commit=false' is called, block
|
|
// any future commit attempts.
|
|
if !commit {
|
|
canceled = true
|
|
return
|
|
}
|
|
// If it was ever canceled, block any commits
|
|
if !canceled {
|
|
commitF()
|
|
}
|
|
}
|
|
}
|
|
|
|
// InitRequest initializes an audit log for a request. It returns a function
|
|
// that should be deferred, causing the audit log to be committed when the
|
|
// handler returns.
|
|
func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func()) {
|
|
sw, ok := w.(*tracing.StatusWriter)
|
|
if !ok {
|
|
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
|
|
}
|
|
|
|
req := &Request[T]{
|
|
params: p,
|
|
}
|
|
|
|
return req, func() {
|
|
ctx := context.Background()
|
|
logCtx := p.Request.Context()
|
|
|
|
// If no resources were provided, there's nothing we can audit.
|
|
if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil {
|
|
// If the request action is a login or logout, we always want to audit it even if
|
|
// there is no diff. This is so we can capture events where an API Key is never created
|
|
// because a known user fails to login.
|
|
if req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
|
return
|
|
}
|
|
}
|
|
|
|
diffRaw := []byte("{}")
|
|
// Only generate diffs if the request succeeded
|
|
// and only if we aren't auditing authentication actions
|
|
if sw.Status < 400 &&
|
|
req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
|
diff := Diff(p.Audit, req.Old, req.New)
|
|
|
|
var err error
|
|
diffRaw, err = json.Marshal(diff)
|
|
if err != nil {
|
|
p.Log.Warn(logCtx, "marshal diff", slog.Error(err))
|
|
diffRaw = []byte("{}")
|
|
}
|
|
}
|
|
|
|
additionalFieldsRaw := json.RawMessage("{}")
|
|
|
|
if p.AdditionalFields != nil {
|
|
data, err := json.Marshal(p.AdditionalFields)
|
|
if err != nil {
|
|
p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err))
|
|
} else {
|
|
additionalFieldsRaw = json.RawMessage(data)
|
|
}
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
key, ok := httpmw.APIKeyOptional(p.Request)
|
|
switch {
|
|
case ok:
|
|
userID = key.UserID
|
|
case req.UserID != uuid.Nil:
|
|
userID = req.UserID
|
|
default:
|
|
// if we do not have a user associated with the audit action
|
|
// we do not want to audit
|
|
// (this pertains to logins; we don't want to capture non-user login attempts)
|
|
return
|
|
}
|
|
|
|
action := p.Action
|
|
if req.Action != "" {
|
|
action = req.Action
|
|
}
|
|
|
|
ip := database.ParseIP(p.Request.RemoteAddr)
|
|
auditLog := database.AuditLog{
|
|
ID: uuid.New(),
|
|
Time: dbtime.Now(),
|
|
UserID: userID,
|
|
Ip: ip,
|
|
UserAgent: sql.NullString{String: p.Request.UserAgent(), Valid: true},
|
|
ResourceType: either(req.Old, req.New, ResourceType[T], req.params.Action),
|
|
ResourceID: either(req.Old, req.New, ResourceID[T], req.params.Action),
|
|
ResourceTarget: either(req.Old, req.New, ResourceTarget[T], req.params.Action),
|
|
Action: action,
|
|
Diff: diffRaw,
|
|
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
|
|
StatusCode: int32(sw.Status),
|
|
RequestID: httpmw.RequestID(p.Request),
|
|
AdditionalFields: additionalFieldsRaw,
|
|
OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log),
|
|
}
|
|
err := p.Audit.Export(ctx, auditLog)
|
|
if err != nil {
|
|
p.Log.Error(logCtx, "export audit log",
|
|
slog.F("audit_log", auditLog),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// BackgroundAudit creates an audit log for a background event.
|
|
// The audit log is committed upon invocation.
|
|
func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) {
|
|
ip := database.ParseIP(p.IP)
|
|
|
|
diff := Diff(p.Audit, p.Old, p.New)
|
|
var err error
|
|
diffRaw, err := json.Marshal(diff)
|
|
if err != nil {
|
|
p.Log.Warn(ctx, "marshal diff", slog.Error(err))
|
|
diffRaw = []byte("{}")
|
|
}
|
|
|
|
if p.Time.IsZero() {
|
|
p.Time = dbtime.Now()
|
|
} else {
|
|
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
|
|
p.Time = dbtime.Time(p.Time.In(time.UTC))
|
|
}
|
|
if p.AdditionalFields == nil {
|
|
p.AdditionalFields = json.RawMessage("{}")
|
|
}
|
|
|
|
auditLog := database.AuditLog{
|
|
ID: uuid.New(),
|
|
Time: p.Time,
|
|
UserID: p.UserID,
|
|
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
|
|
Ip: ip,
|
|
UserAgent: sql.NullString{Valid: p.UserAgent != "", String: p.UserAgent},
|
|
ResourceType: either(p.Old, p.New, ResourceType[T], p.Action),
|
|
ResourceID: either(p.Old, p.New, ResourceID[T], p.Action),
|
|
ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action),
|
|
Action: p.Action,
|
|
Diff: diffRaw,
|
|
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
|
|
StatusCode: int32(p.Status),
|
|
RequestID: p.RequestID,
|
|
AdditionalFields: p.AdditionalFields,
|
|
}
|
|
err = p.Audit.Export(ctx, auditLog)
|
|
if err != nil {
|
|
p.Log.Error(ctx, "export audit log",
|
|
slog.F("audit_log", auditLog),
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
type WorkspaceBuildBaggage struct {
|
|
IP string
|
|
}
|
|
|
|
func (b WorkspaceBuildBaggage) Props() ([]baggage.Property, error) {
|
|
ipProp, err := baggage.NewKeyValueProperty("ip", b.IP)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("create ip kv property: %w", err)
|
|
}
|
|
|
|
return []baggage.Property{ipProp}, nil
|
|
}
|
|
|
|
func WorkspaceBuildBaggageFromRequest(r *http.Request) WorkspaceBuildBaggage {
|
|
return WorkspaceBuildBaggage{IP: r.RemoteAddr}
|
|
}
|
|
|
|
type Baggage interface {
|
|
Props() ([]baggage.Property, error)
|
|
}
|
|
|
|
func BaggageToContext(ctx context.Context, d Baggage) (context.Context, error) {
|
|
props, err := d.Props()
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create baggage properties: %w", err)
|
|
}
|
|
|
|
m, err := baggage.NewMember("audit", "baggage", props...)
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create new baggage member: %w", err)
|
|
}
|
|
|
|
b, err := baggage.New(m)
|
|
if err != nil {
|
|
return ctx, xerrors.Errorf("create new baggage carrier: %w", err)
|
|
}
|
|
|
|
return baggage.ContextWithBaggage(ctx, b), nil
|
|
}
|
|
|
|
func BaggageFromContext(ctx context.Context) WorkspaceBuildBaggage {
|
|
d := WorkspaceBuildBaggage{}
|
|
b := baggage.FromContext(ctx)
|
|
props := b.Member("audit").Properties()
|
|
for _, prop := range props {
|
|
switch prop.Key() {
|
|
case "ip":
|
|
d.IP, _ = prop.Value()
|
|
default:
|
|
}
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
func either[T Auditable, R any](old, newVal T, fn func(T) R, auditAction database.AuditAction) R {
|
|
switch {
|
|
case ResourceID(newVal) != uuid.Nil:
|
|
return fn(newVal)
|
|
case ResourceID(old) != uuid.Nil:
|
|
return fn(old)
|
|
case auditAction == database.AuditActionLogin || auditAction == database.AuditActionLogout:
|
|
// If the request action is a login or logout, we always want to audit it even if
|
|
// there is no diff. See the comment in audit.InitRequest for more detail.
|
|
return fn(old)
|
|
default:
|
|
panic("both old and new are nil")
|
|
}
|
|
}
|