mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## 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 (this PR) 2. #30610 organization skill API routes and the scope-neutral SDK 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 data model and authorization for organization skills. Personal skills keep working unchanged: `git diff origin/main -- codersdk site` shows only generated RBAC and API key scope additions (`apikey_scopes_gen.go`, `rbacresources_gen.go`, `rbacresourcesGenerated.ts`, the RBAC and scope unions in `typesGenerated.ts`) and `codersdk.ResourceTypeOrganizationSkill` with its friendly string. No personal route, SDK type or site component changes, and `coderd/userskills_test.go` changes only by mechanical constant renames. **One migration, `000621_skills`** (please review it closely): - renames `user_skills` to `skills` with its constraints, index and triggers, and recreates the cap function and `delete_deleted_user_resources`, whose PL/pgSQL bodies name the table; - adds `organization_id` and `project_id` (`ON DELETE CASCADE`), `enabled` (default true), and `group_acl` / `user_acl` (`jsonb`, default `{}`), with CHECKs for exactly one owner, ACLs that are JSON objects, and ACLs that stay empty unless an organization owns the row, plus partial unique name indexes per owner kind; - replaces the cap trigger with `enforce_skills_per_owner_limit()`: 100 rows per owner, disabled rows included, locking the owner row `FOR NO KEY UPDATE` so unrelated foreign key checks are not blocked; - adds a `BEFORE DELETE` trigger on `organization_members` that removes a departing member from `user_acl` on that organization's skills, so rejoining doesn't restore a direct grant; - adds the `organization_skill` `resource_type` and `api_key_scope` enum values last; nothing in the migration uses them. The down migration undoes the three old migrations in reverse order: it drops organization and project rows and restores `user_skills`, its names, the cap function and `user_id NOT NULL`. Authorization: a new RBAC resource `organization_skill` (create, read, update, delete, share). Owner and org admin get it; site and org auditors get read; members read only through the skill's ACL, which applies only to members of its organization. `Skill.RBACObject()` returns the organization object with both ACLs; project rows stay denied to everyone. New sqlc queries cover organization insert, get, list (SQL-filtered through `GetAuthorizedOrganizationSkillMetadata` and a `regosql` converter), update and delete, with dbauthz wrappers and the audit table entries. No routes yet. ## Commits The first commit reproduces the reviewed code from the old stack; each later commit is a labeled trim or fix. - `baa3150e3b7` feat: add the organization skills data model. Reproduction: every non-site file this commit changes equals the reviewed old `authz` head `1e16b3004c4`, except that the old stack's migrations 000615, 000616 and 000617 are merged into one migration (then `000615_skills`, now `000621_skills`) with one fixture (`testdata/fixtures/000621_skills.up.sql`) and one migration test (now `testMigration000621Skills`). At that commit, `dump.sql` was byte-identical to the old head's. - `1b84e83463a` test(coderd/database): keep only the SQL paths of the org skill matrix. `TestOrganizationSkillAuthorization` becomes `TestOrganizationSkillQueries` instead of being deleted, because coverage showed it is this PR's only run of the `GetAuthorizedOrganizationSkillMetadata` row scan (80% to 65% without it) and of the organization update and delete queries (100% to 0%). Swapping `group_acl` and `user_acl` in the converter makes `GrantedMember` fail. The removed cases are decided by `TestRolePermissions`, `TestSkillRBACObjectOrganizationACL` and the dbauthz method suite. - `e2931b412e9` test(coderd/database): fold the skill cap cases into existing coverage. The user case is covered by `TestUserSkillLimit` and `TestUserSkillLimitConcurrentCreates`; the organization case folds into `ConcurrentOrganizationInsertsHonorCap`, which now also checks that disabled rows count and another organization is unaffected (mutation checked); the project case stays. - `be62aea9021` test(coderd/rbac): drop `TestOrganizationSkillReadScope`, covered by `TestExternalScopeNames` and the generated scope file. - `0c275ba37de` merges main at `454a2fcc697`, which took 000615 to 000618: it renumbers the migration to 000619 and resolves `dump.sql`, `check_constraint.go` and the generated API docs. `8f2b4b87ee2` merges main at `ad85646164e`, which took 000619: it renumbers the migration to 000620. - `5248d56b64f` merges main at `e7b76872098`, which took 000620 (`000620_audit_chat_project_instructions_resource_type`): it renumbers the migration, fixture and migration test to `000621_skills` (step `Migration000621Skills`, version 621), and resolves `enterprise/audit/table.go` (keeping main's `AuditableChatProjectInstructions` entries and the `Skill` entry), `dump.sql` and `models.go` (the `resource_type` enum gets `chat_project_instructions`, then `organization_skill`) and the regenerated `schemas.md`. The up and down SQL are unchanged by every renumbering. ## Size Production +549/-67, tests +728/-33, generated +881/-314; total +2158/-414. 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 #30584 (rename `user_skills` to `skills`), #30585 (skill owners), #30586 (organization skill authorization). ## Validation - Migration test: up keeps the existing personal row and its ID; down restores the `user_skills` names, cap function and `NOT NULL`, and drops organization rows; soft-deleting a user removes their skills after up, after down, and after re-up. - `go test` passed on `be62aea9021`, before the main merges, for `coderd` (skill tests), `coderd/database`, `dbauthz`, `migrations`, `gentest`, `coderd/rbac/...`, `coderd/x/chatd`, `coderd/x/skills`, `enterprise/audit` and `enterprise/coderd`; `make lint/check-scopes` passed; the four personal skills Vitest files pass unchanged. - On `5248d56b64f`: build, vet, `TestMigrate`, `TestMigrationChain`, the `migrations`, `database`, `dbauthz`, `gentest`, `rbac` and `enterprise/audit` packages pass; `make gen` run twice is clean, and `make pre-commit` passes. - 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.
761 lines
23 KiB
Go
761 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.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.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.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.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")
|
|
}
|
|
}
|