mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
Adds an audit record for administrative events on the deployment-wide
chat instruction settings (system prompt, the include-default toggle,
and the plan-mode instructions), per CODAGT-719 and operator decision
D5. Each endpoint records under a stable identity: resource type
`chat_instruction_settings`, a fixed resource ID and a human-readable
target ("System prompt", "Plan mode instructions"), so two changes to
one setting share an ID and history-by-setting works. A real change
exports a Write entry with the old-to-new text visible; a
value-identical PUT still upserts and still returns 204 but records
nothing.
Attempts are recorded, not only transitions. Identity is assigned before
the authorization check, so a denied PUT exports a 403 row with an empty
diff (no request content reaches it), a validation failure exports a 400
row, and a write failure exports a 500 row, each with an empty diff; an
operator can tell "nothing changed" from "something changed and capture
degraded" by the status code.
The write path stays authoritative. The advisory lock and, on plan-mode,
the transaction exist only to serve change-detection; if any of that
machinery fails (lock, begin, commit, rollback), the handler runs main's
idempotent write path directly and derives the response from it, so a
member-visible failure of audit-only infrastructure can never replace
main's successful response. Accepted consequence: when the lock cannot
be taken, two concurrent identical writes can produce two rows instead
of one. That is audit degradation, which is allowed; changing a member's
response is not. Write failures keep the exact response the endpoint
produced before this wiring (transaction error for the system prompt,
which was always transactional; the raw write error for plan mode, which
was not), and the full transaction error is logged so rollback failures
cannot vanish.
<details>
<summary>CODAGT-66 plan entry: S1 (verbatim)</summary>
**S1 `feat: audit chat system instructions changes`** (CODAGT-719; base:
main)
- Struct: `database.ChatSystemPromptSettings{ID uuid.UUID; SystemPrompt
string; IncludeDefaultSystemPrompt bool; PlanModeInstructions string}`
in `coderd/database/types.go` (ticket-sketched shape; one struct, both
endpoints).
- Registration: union entry (diff.go), table.go entry (`id`
ActionIgnore, other three ActionTrack), `AuditActionMap` Write-only;
four request.go cases (`ResourceTarget` "", `ResourceID` from struct,
`ResourceType` new enum value `chat_system_prompt_settings`,
`ResourceRequiresOrgID` false with the "Artificial ID / deployment
singleton" comment convention).
- Migration: `ALTER TYPE resource_type ADD VALUE IF NOT EXISTS
'chat_system_prompt_settings';` comment-only no-op down (000558 shape);
number picked at push per the numbering constraint.
- codersdk: constant + prose `FriendlyString` ("chat system prompt
settings"); `TestAuditDBEnumsCovered` forces both. `coderd/audit.go`
presentation switches: rely on safe defaults (no link, generic
description); no FE changes (filter label falls back to capitalized
value; acceptable per precedent).
- Wiring `putChatSystemPrompt` and `putChatPlanModeInstructions`:
InitRequest with Action Write; artificial `ID: uuid.New()` on `New` only
when a change is detected; no-op suppression by leaving both aReq sides
unset (nil resource IDs skip the log, request.go skip rule); the write
path itself stays byte-identical (upserts still run unconditionally).
- `putChatSystemPrompt` (writes two keys conditionally in one existing
tx): inside that tx, read the pair via `GetChatSystemPromptConfig` for
`Old`, perform the conditional writes exactly as today, then RE-READ the
pair for `New`. The re-read is load-bearing:
`include_default_system_prompt` is computed from the toggle row AND the
prompt, so a prompt-only write can flip the effective value without the
request carrying the pointer. `PlanModeInstructions` stays zero on both
sides.
- `putChatPlanModeInstructions` (no tx exists today): wrap its
read-upsert in `InTx` (behavior-preserving: same single write);
`Old`/`New` populate only `PlanModeInstructions`; the two system-prompt
fields stay zero on both sides; no cross-key reads.
- Change detection compares the populated payload fields only (never the
artificial ID).
- Tests: handler-level coderdtest with `audit.NewMock()` asserting Write
entry on change and NO entry on a value-identical PUT, for both
endpoints (this also exercises `ResourceRequiresOrgID` end to end); the
fallback-flip case (no explicit include-default row, nonempty prompt set
to empty, effective boolean flips: entry emitted with the boolean diff);
diff assertions (old->new prompt text tracked, not secret) in
`enterprise/audit/diff_internal_test.go`; `TestAuditableResources`
passes by construction.
- Bookkeeping at PR open: correct CODAGT-719's no-op premise ("matches
the existing 204-on-unchanged behavior" does not exist on main;
suppression is new, write path unchanged).
- Review focus: Old capture and the New re-read inside the tx (three of
four existing singletons never set Old; do not copy them; and the
computed include-default value makes a naive New construction wrong);
the skip-on-no-op mechanism; prompt text deliberately visible in diffs.
</details>
Note: the plan excerpt above predates operator decision D5 (2026-07-30),
which this PR implements: the resource type is
`chat_instruction_settings` (not `chat_system_prompt_settings`), each
setting carries a stable ID and a display-name target (not a per-write
artificial ID and an empty target), no-op suppression runs through
`InitRequestWithCancel` (not the nil-ID skip), and attempts (denied,
failed, capture-degraded) record rows with real statuses and empty
diffs. Ticket bookkeeping for CODAGT-719 was corrected on Linear at
kickoff: the ticket's "matches the existing 204-on-unchanged behavior"
premise does not exist on main; suppression is new, and the write path
is unchanged.
> 🤖 This PR was created with the help of Coder Agents, and _will be_
reviewed by a human. 🏂🏻
---------
Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
303 lines
10 KiB
Go
303 lines
10 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ResourceType string
|
|
|
|
const (
|
|
ResourceTypeTemplate ResourceType = "template"
|
|
ResourceTypeTemplateVersion ResourceType = "template_version"
|
|
ResourceTypeUser ResourceType = "user"
|
|
ResourceTypeWorkspace ResourceType = "workspace"
|
|
ResourceTypeWorkspaceBuild ResourceType = "workspace_build"
|
|
ResourceTypeGitSSHKey ResourceType = "git_ssh_key"
|
|
ResourceTypeAPIKey ResourceType = "api_key"
|
|
ResourceTypeGroup ResourceType = "group"
|
|
ResourceTypeLicense ResourceType = "license"
|
|
ResourceTypeConvertLogin ResourceType = "convert_login"
|
|
ResourceTypeHealthSettings ResourceType = "health_settings"
|
|
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
|
|
ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
|
|
ResourceTypeOAuth2ProviderSettings ResourceType = "oauth2_provider_settings"
|
|
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
|
|
ResourceTypeOrganization ResourceType = "organization"
|
|
ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
|
|
// nolint:gosec // This is not a secret.
|
|
ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
|
|
ResourceTypeCustomRole ResourceType = "custom_role"
|
|
ResourceTypeOrganizationMember ResourceType = "organization_member"
|
|
ResourceTypeNotificationTemplate ResourceType = "notification_template"
|
|
ResourceTypeIdpSyncSettingsOrganization ResourceType = "idp_sync_settings_organization"
|
|
ResourceTypeIdpSyncSettingsGroup ResourceType = "idp_sync_settings_group"
|
|
ResourceTypeIdpSyncSettingsRole ResourceType = "idp_sync_settings_role"
|
|
// Deprecated: Workspace Agent connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceApp ResourceType = "workspace_app"
|
|
ResourceTypeTask ResourceType = "task"
|
|
ResourceTypeAISeat ResourceType = "ai_seat"
|
|
ResourceTypeAIProvider ResourceType = "ai_provider"
|
|
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
|
|
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
|
|
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
|
|
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
|
|
ResourceTypeChat ResourceType = "chat"
|
|
ResourceTypeUserSecret ResourceType = "user_secret"
|
|
ResourceTypeUserSkill ResourceType = "user_skill"
|
|
ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
|
|
)
|
|
|
|
func (r ResourceType) FriendlyString() string {
|
|
switch r {
|
|
case ResourceTypeTemplate:
|
|
return "template"
|
|
case ResourceTypeTemplateVersion:
|
|
return "template version"
|
|
case ResourceTypeUser:
|
|
return "user"
|
|
case ResourceTypeWorkspace:
|
|
return "workspace"
|
|
case ResourceTypeWorkspaceBuild:
|
|
// workspace builds have a unique friendly string
|
|
// see coderd/audit.go:298 for explanation
|
|
return "workspace"
|
|
case ResourceTypeGitSSHKey:
|
|
return "git ssh key"
|
|
case ResourceTypeAPIKey:
|
|
return "token"
|
|
case ResourceTypeGroup:
|
|
return "group"
|
|
case ResourceTypeLicense:
|
|
return "license"
|
|
case ResourceTypeConvertLogin:
|
|
return "login type conversion"
|
|
case ResourceTypeWorkspaceProxy:
|
|
return "workspace proxy"
|
|
case ResourceTypeOrganization:
|
|
return "organization"
|
|
case ResourceTypeHealthSettings:
|
|
return "health_settings"
|
|
case ResourceTypeNotificationsSettings:
|
|
return "notifications_settings"
|
|
case ResourceTypePrebuildsSettings:
|
|
return "prebuilds_settings"
|
|
case ResourceTypeOAuth2ProviderSettings:
|
|
return "oauth2 provider settings"
|
|
case ResourceTypeOAuth2ProviderApp:
|
|
return "oauth2 app"
|
|
case ResourceTypeOAuth2ProviderAppSecret:
|
|
return "oauth2 app secret"
|
|
case ResourceTypeCustomRole:
|
|
return "custom role"
|
|
case ResourceTypeOrganizationMember:
|
|
return "organization member"
|
|
case ResourceTypeNotificationTemplate:
|
|
return "notification template"
|
|
case ResourceTypeIdpSyncSettingsOrganization:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsGroup:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsRole:
|
|
return "settings"
|
|
case ResourceTypeWorkspaceAgent:
|
|
return "workspace agent"
|
|
case ResourceTypeWorkspaceApp:
|
|
return "workspace app"
|
|
case ResourceTypeTask:
|
|
return "task"
|
|
case ResourceTypeAISeat:
|
|
return "ai seat"
|
|
case ResourceTypeAIProvider:
|
|
return "ai provider"
|
|
case ResourceTypeAIProviderKey:
|
|
return "ai provider key"
|
|
case ResourceTypeAIGatewayKey:
|
|
return "ai gateway key"
|
|
case ResourceTypeGroupAIBudget:
|
|
return "group ai budget"
|
|
case ResourceTypeUserAIBudgetOverride:
|
|
return "user ai budget override"
|
|
case ResourceTypeChat:
|
|
return "chat"
|
|
case ResourceTypeUserSecret:
|
|
return "user secret"
|
|
case ResourceTypeUserSkill:
|
|
return "user skill"
|
|
case ResourceTypeChatInstructionSettings:
|
|
return "chat instruction settings"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditAction string
|
|
|
|
const (
|
|
AuditActionCreate AuditAction = "create"
|
|
AuditActionWrite AuditAction = "write"
|
|
AuditActionDelete AuditAction = "delete"
|
|
AuditActionStart AuditAction = "start"
|
|
AuditActionStop AuditAction = "stop"
|
|
AuditActionLogin AuditAction = "login"
|
|
AuditActionLogout AuditAction = "logout"
|
|
AuditActionRegister AuditAction = "register"
|
|
AuditActionRequestPasswordReset AuditAction = "request_password_reset"
|
|
// Deprecated: Workspace connections are now included in the
|
|
// connection log.
|
|
AuditActionConnect AuditAction = "connect"
|
|
// Deprecated: Workspace disconnections are now included in the
|
|
// connection log.
|
|
AuditActionDisconnect AuditAction = "disconnect"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
AuditActionOpen AuditAction = "open"
|
|
// Deprecated: This action is unused.
|
|
AuditActionClose AuditAction = "close"
|
|
)
|
|
|
|
func (a AuditAction) Friendly() string {
|
|
switch a {
|
|
case AuditActionCreate:
|
|
return "created"
|
|
case AuditActionWrite:
|
|
return "updated"
|
|
case AuditActionDelete:
|
|
return "deleted"
|
|
case AuditActionStart:
|
|
return "started"
|
|
case AuditActionStop:
|
|
return "stopped"
|
|
case AuditActionLogin:
|
|
return "logged in"
|
|
case AuditActionLogout:
|
|
return "logged out"
|
|
case AuditActionRegister:
|
|
return "registered"
|
|
case AuditActionRequestPasswordReset:
|
|
return "password reset requested"
|
|
case AuditActionConnect:
|
|
return "connected"
|
|
case AuditActionDisconnect:
|
|
return "disconnected"
|
|
case AuditActionOpen:
|
|
return "opened"
|
|
case AuditActionClose:
|
|
return "closed"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditDiff map[string]AuditDiffField
|
|
|
|
type AuditDiffField struct {
|
|
Old any `json:"old,omitempty"`
|
|
New any `json:"new,omitempty"`
|
|
Secret bool `json:"secret"`
|
|
}
|
|
|
|
type AuditLog struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id" format:"uuid"`
|
|
Time time.Time `json:"time" format:"date-time"`
|
|
IP netip.Addr `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID uuid.UUID `json:"resource_id" format:"uuid"`
|
|
// ResourceTarget is the name of the resource.
|
|
ResourceTarget string `json:"resource_target"`
|
|
ResourceIcon string `json:"resource_icon"`
|
|
Action AuditAction `json:"action"`
|
|
Diff AuditDiff `json:"diff"`
|
|
StatusCode int32 `json:"status_code"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields" swaggertype:"object"`
|
|
Description string `json:"description"`
|
|
ResourceLink string `json:"resource_link"`
|
|
IsDeleted bool `json:"is_deleted"`
|
|
|
|
// Deprecated: Use 'organization.id' instead.
|
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
|
|
|
Organization *MinimalOrganization `json:"organization,omitempty"`
|
|
|
|
User *User `json:"user"`
|
|
}
|
|
|
|
type AuditLogsRequest struct {
|
|
SearchQuery string `json:"q,omitempty"`
|
|
Pagination
|
|
}
|
|
|
|
type AuditLogResponse struct {
|
|
AuditLogs []AuditLog `json:"audit_logs"`
|
|
Count int64 `json:"count"`
|
|
CountCap int64 `json:"count_cap"`
|
|
}
|
|
|
|
type CreateTestAuditLogRequest struct {
|
|
Action AuditAction `json:"action,omitempty" enums:"create,write,delete,start,stop"`
|
|
ResourceType ResourceType `json:"resource_type,omitempty" enums:"template,template_version,user,workspace,workspace_build,git_ssh_key,auditable_group"`
|
|
ResourceID uuid.UUID `json:"resource_id,omitempty" format:"uuid"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
|
|
Time time.Time `json:"time,omitempty" format:"date-time"`
|
|
BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"`
|
|
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id,omitempty" format:"uuid"`
|
|
}
|
|
|
|
// AuditLogs retrieves audit logs from the given page.
|
|
func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, req.Pagination.asRequestOption(), func(r *http.Request) {
|
|
q := r.URL.Query()
|
|
var params []string
|
|
if req.SearchQuery != "" {
|
|
params = append(params, req.SearchQuery)
|
|
}
|
|
q.Set("q", strings.Join(params, " "))
|
|
r.URL.RawQuery = q.Encode()
|
|
})
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuditLogResponse{}, ReadBodyAsError(res)
|
|
}
|
|
|
|
var logRes AuditLogResponse
|
|
err = ReadBodyAsJSON(res, &logRes)
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
|
|
return logRes, nil
|
|
}
|
|
|
|
// CreateTestAuditLog creates a fake audit log. Only owners of the organization
|
|
// can perform this action. It's used for testing purposes.
|
|
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|