Files
coder/coderd/audit/request.go
T
Ethan aefc761fcf feat!: enforce organization scope for chat models (#27959)
## Summary

Move chat model management and runtime availability to
organization-scoped API routes. The API now identifies the organization
in every model-management operation, applies chat model RBAC and ACLs
within that organization, and returns only models the caller can use
there.

Depends on #27958.

## Problem

Chat model configurations now belong to organizations, but the existing
experimental management and discovery APIs are deployment-shaped. Their
routes do not identify an organization, SDK methods do not accept an
organization ID, and item operations address a model configuration by ID
alone.

That contract cannot safely expose organization-owned model
configurations. It also does not provide the organization-specific
provider information, authorization behavior, ACL management, or
recovery guidance needed by the settings and chat clients later in the
stack.

## Fix

Add organization-scoped chat model endpoints under:

```text
/api/experimental/organizations/{organization}/chats/models
```

The new API provides:

- Organization-scoped model listing and creation.
- Model get, update, and delete operations scoped by both organization
and model ID.
- Model ACL read and update operations.
- Organization-scoped runtime availability and provider status.
- Redacted provider descriptors for model-management clients.
- Chat model RBAC and row ACL enforcement for writes, runtime discovery,
and Chatd model selection.
- Recovery guidance when a chat's organization has no usable local
model.

Historical chats can retain a model reference owned by another
organization, but runtime selection uses an authorized local default
when one is available. Foreign, inaccessible, disabled, and otherwise
unusable models are not exposed as usable selections for the
organization.

The existing collection and availability routes remain as
default-organization compatibility endpoints in this layer:

```text
GET  /api/experimental/chats/model-configs
POST /api/experimental/chats/model-configs
GET  /api/experimental/chats/models
```

These compatibility routes do not provide access to models in
non-default organizations. The previous unscoped item update and delete
routes are removed because item operations must include the owning
organization.

## Breaking change

This changes the experimental HTTP API and Go SDK contracts for chat
model management.

Clients must migrate model-management and availability requests to the
organization-scoped routes and provide an organization identifier. In
the Go SDK, the following methods now require an organization ID:

- `ChatModelAvailability`
- `ChatModels`
- `CreateChatModel`
- `UpdateChatModel`
- `DeleteChatModel`

`ChatModels` also changes its return value from `[]ChatModel` to
`OrganizationChatModelsResponse`, which includes the organization models
and the redacted provider descriptors required by management clients.
New item and ACL methods likewise require both the organization ID and
model ID.

The default-organization compatibility routes reduce the immediate
impact for collection and availability callers, but they do not preserve
the previous item API or SDK method signatures. Generated clients and
direct consumers of the experimental API must be updated before adopting
this change.

_This pull request description was generated by Coder Agents._
2026-08-24 11:20:25 +01:00

718 lines
22 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.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.ChatModelConfig:
return cmp.Or(typed.DisplayName, typed.Model, 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.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.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.ChatModelConfig:
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.ChatModelConfig:
return database.ResourceTypeChatModelConfig
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.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.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")
}
}