Files
coder/coderd/audit/request.go
T
Michael Suchacz db7f5a7703 feat: add organization skill API routes (#30610)
## Stack

Part of
[CODAGT-1341](https://linear.app/codercom/issue/CODAGT-1341/support-organization-level-skills-in-coder-agents).
This stack adds organization skills to Coder Agents: admins publish a
skill once per organization, share it with groups or members, and chats
in that organization load it next to personal and workspace skills. It
replaces the 21-PR draft stack #30584 to #30604 with five PRs, bottom to
top:

1. #30609 data model: one migration, queries, RBAC and dbauthz
2. **#30610** organization skill API routes and the scope-neutral SDK
(this PR)
3. #30611 sharing ACL routes
4. #30612 settings UI: shared skills table, admin page, member section
5. #30613 chats: chatd loading, `/` menu, docs

## What changes

This PR adds the organization skill CRUD routes and makes the skill SDK
types owner-neutral so personal and organization skills share them.

**API contract change (experimental API, personal and organization
skills):** skill list and get responses gain `enabled`, and `PATCH
.../skills/{skillName}` now takes `{content?, enabled?}`, so `content`
is optional and a toggle doesn't resend it. A PATCH with neither field
returns 400; `content`, when present, is validated as before. In the
SDK, `UserSkill`, `UserSkillMetadata`, `CreateUserSkillRequest` and
`UpdateUserSkillRequest` become `Skill`, `SkillMetadata`,
`CreateSkillRequest` and `UpdateSkillRequest` (`codersdk/userskills.go`
moves to `codersdk/skills.go`), and `UpdateSkillRequest.Content` is now
`*string`. The personal client method names are unchanged. The site
changes are the handwritten edits that rename forces (`api.ts`,
`queries/userSkills.ts`, `testHelpers/skills.ts` and three call sites),
with no UI change.

Organization routes under
`/api/experimental/organizations/{organization}/skills`, not gated by an
experiment or license, like the personal ones:

- `GET` lists with the caller's own permissions, so admins, the site
owner and org auditors see every skill and members see only skills
shared with them. `GET /{skillName}` goes through the new
`httpmw.ExtractOrganizationSkillParam` and admits only read, so a
missing skill and a denied one both return 404.
- `POST` checks `create` on the organization before reading the body,
then seeds `group_acl` with the Everyone group (whose ID is the
organization ID) so a new skill reaches every member until an admin
narrows it. The 100-skill cap and a duplicate name return 409.
- `PATCH` admits only callers who can read the skill, because its
response includes the content; `DELETE` admits callers who can read or
delete it. A reader who can't change the skill gets a write-specific 403
from the database wrapper, and everyone else gets 404. PATCH locks the
row as system, updates as the caller in the same transaction, and audits
the locked row only after the transaction commits.
- Audit: `database.AuditableOrganizationSkill` records resource type
`organization_skill` with the organization ID; personal skills keep
`user_skill`.

Personal and organization handlers share one create and update path in
`coderd/userskills.go` (`createSkill`, `updateSkill`, and a
`skillWriteErrors` table per owner and action).

## Commits

The first commit reproduces the reviewed code from the old stack; each
later commit is a labeled trim or fix.

- `49cb2c84276` feat: add organization skill API routes. Reproduction:
every file this commit changes, including the SDK layer's site edits,
equals the reviewed old `write-routes` head `aa597100f28`; the rest of
the tree differs only by #30609's migration merge and main.
- `dc893bc60d6` refactor(coderd): share the skill create and update path
across owners (production +164/-188). It keeps the organization create
check before the body read, the lock as system then update as caller
order, and audit assignment after the transaction. No swagger,
`codersdk` or `typesGenerated.ts` change; `make gen` is clean.
- `f94925425ef` test(coderd): merge duplicate personal and organization
skill tests. `TestSkillsCRUD`, `TestSkillValidationAndConflicts` and
`TestSkillAudit` are table-driven over Personal, OrgAdmin and SiteOwner,
alongside `TestOrganizationSkillAuditsDeniedWrites` and one
`ScopedAPIKeys` subtest; `TestPatchUserSkill` is deleted because none of
its blocks were unique. Coverage of `userskills.go`,
`organizationskills.go` and `httpmw/organizationskillparam.go` is 101 of
117 blocks before and after, the same block set.
- `ad0a7a37504` fix(coderd): require read access to patch an
organization skill. The organization PATCH route now admits only `read`,
because its response carries the skill's content: a key scoped to
`organization_skill:update` without read gets 404, like GET, instead of
200 with the content. Readers without update still get 403, and DELETE
is unchanged. `TestOrganizationSkillAccess/ScopedAPIKeys` asserts the
404, that the response has no content, and that the skill is unchanged;
against the old route it fails with 200.
- `69d00ded0b6` and `f67b79343ec` merge the updated #30609 layer,
including main; `69d00ded0b6` resolves a conflict in the personal skills
page with main. No other changes of their own.
- `18373ffd59e` merges the updated #30609 layer (main at `e7b76872098`)
and reapplies the `AuditableOrganizationSkill` entry in
`enterprise/audit/table.go` alongside main's new
`AuditableChatProjectInstructions` entries.

## Size

Production +838/-308, tests +733/-322, generated +801/-274; total
+2372/-904. This is above the 500-line reviewer-friendly limit on
purpose: Mike asked for 4 to 5 PRs instead of the 21 small ones this
stack replaces.

## Replaces

#30587 (scope-neutral SDK and personal enabled state), #30588 (read
routes), #30589 (write routes and audit).

## Validation

- The coverage comparison above, before and after the test merge.
- `make gen` leaves no diff after the shared create and update refactor.
- On `18373ffd59e`: `make gen` is clean; build, vet, the `coderd` and
`enterprise/coderd` skill tests, `rbac`, `httpmw`, `dbauthz` and
`enterprise/audit` pass; `pnpm check`, `pnpm lint` and `make pre-commit`
pass.
- Remote UAT round 7 through Coder Agents passed on the whole stack; see
#30613's Validation for scope and findings.

> Xum opened this PR on behalf of @ibetitsmike.
2026-10-13 16:24:45 +00:00

769 lines
23 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.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.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.ChatProject:
return typed.Name
case database.ChatProjectMemory:
return typed.Name
case database.AuditableChatProjectInstructions:
return typed.ProjectName
case database.ChatModelConfig:
return cmp.Or(typed.DisplayName, typed.ID.String())
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.Skill:
return typed.Name
case database.AuditableOrganizationSkill:
return typed.Name
case database.ChatInstructionSettings:
return typed.Name
case database.ChatOperationalSettings:
return ""
case database.ChatOrganizationSystemPrompt:
return ""
case database.ExperimentRule:
return typed.Experiment
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.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.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.ChatProject:
return typed.ID
case database.ChatProjectMemory:
return typed.ID
case database.AuditableChatProjectInstructions:
// One row per project, keyed by the project.
return typed.ProjectID
case database.ChatModelConfig:
return typed.ID
case database.MCPServerConfig:
return typed.ID
case database.UserSecret:
return typed.ID
case database.Skill:
return typed.ID
case database.AuditableOrganizationSkill:
return typed.ID
case database.ChatInstructionSettings:
// Fixed ID per setting; see ChatInstructionSettings IDs.
return typed.ID
case database.ChatOperationalSettings:
return typed.ID
case database.ChatOrganizationSystemPrompt:
return typed.OrganizationID
case database.ExperimentRule:
// Derived from the experiment name by experiments.AuditRecord.
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.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.ChatProject:
return database.ResourceTypeChatProject
case database.ChatProjectMemory:
return database.ResourceTypeChatProjectMemory
case database.AuditableChatProjectInstructions:
return database.ResourceTypeChatProjectInstructions
case database.ChatModelConfig:
return database.ResourceTypeChatModelConfig
case database.MCPServerConfig:
return database.ResourceTypeMCPServerConfig
case database.UserSecret:
return database.ResourceTypeUserSecret
case database.Skill:
return database.ResourceTypeUserSkill
case database.AuditableOrganizationSkill:
return database.ResourceTypeOrganizationSkill
case database.ChatInstructionSettings:
return database.ResourceTypeChatInstructionSettings
case database.ChatOperationalSettings:
return database.ResourceTypeChatOperationalSettings
case database.ChatOrganizationSystemPrompt:
return database.ResourceTypeChatOrganizationSystemPrompt
case database.ExperimentRule:
return database.ResourceTypeExperimentRule
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.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.ChatProject:
return true
case database.ChatProjectMemory:
return true
case database.AuditableChatProjectInstructions:
return true
case database.ChatModelConfig:
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.Skill:
// User skills are global to the user across organizations.
return false
case database.AuditableOrganizationSkill:
return true
case database.ChatInstructionSettings:
// Deployment settings, not scoped to any organization.
return false
case database.ChatOperationalSettings:
return false
case database.ChatOrganizationSystemPrompt:
return true
case database.ExperimentRule:
// 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")
}
}