mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
## 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._
718 lines
22 KiB
Go
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")
|
|
}
|
|
}
|