diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a8bc67c8a5..022da464d8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -22822,6 +22822,7 @@ const docTemplate = `{ "ai_provider_key", "ai_gateway_key", "group_ai_budget", + "user_ai_budget_override", "chat", "user_secret", "user_skill" @@ -22858,6 +22859,7 @@ const docTemplate = `{ "ResourceTypeAIProviderKey", "ResourceTypeAIGatewayKey", "ResourceTypeGroupAIBudget", + "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", "ResourceTypeUserSkill" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5725db5c9b..caf7f1f312 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -20929,6 +20929,7 @@ "ai_provider_key", "ai_gateway_key", "group_ai_budget", + "user_ai_budget_override", "chat", "user_secret", "user_skill" @@ -20965,6 +20966,7 @@ "ResourceTypeAIProviderKey", "ResourceTypeAIGatewayKey", "ResourceTypeGroupAIBudget", + "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeUserSecret", "ResourceTypeUserSkill" diff --git a/coderd/audit.go b/coderd/audit.go index a58168567f..3b38f8a7c2 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -620,6 +620,10 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit return "" } return fmt.Sprintf("/organizations/%s/groups/%s", org.Name, group.Name) + case database.ResourceTypeUserAiBudgetOverride: + // TODO: point at the user's AI budget override management page + // once it ships. Until then, the audit row links nowhere. + return "" default: return "" } diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 0beec46153..49232f8d69 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -39,6 +39,7 @@ type Auditable interface { database.AIGatewayKey | database.Chat | database.AuditableGroupAiBudget | + database.AuditableUserAiBudgetOverride | database.UserSecret | database.UserSkill } diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 2304d37e82..49162990c2 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -142,6 +142,8 @@ func ResourceTarget[T Auditable](tgt T) string { 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 @@ -228,6 +230,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.AuditableGroupAiBudget: return typed.GroupID + case database.AuditableUserAiBudgetOverride: + return typed.UserID case database.Chat: return typed.ID case database.UserSecret: @@ -299,6 +303,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeAIGatewayKey case database.AuditableGroupAiBudget: return database.ResourceTypeGroupAiBudget + case database.AuditableUserAiBudgetOverride: + return database.ResourceTypeUserAiBudgetOverride case database.Chat: return database.ResourceTypeChat case database.UserSecret: @@ -378,6 +384,10 @@ func ResourceRequiresOrgID[T Auditable]() bool { 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). diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9d2b8e3fc5..3a7b27894c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -569,7 +569,8 @@ CREATE TYPE resource_type AS ENUM ( 'ai_provider_key', 'group_ai_budget', 'user_skill', - 'ai_gateway_key' + 'ai_gateway_key', + 'user_ai_budget_override' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.down.sql b/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.down.sql new file mode 100644 index 0000000000..d952e380f3 --- /dev/null +++ b/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.down.sql @@ -0,0 +1 @@ +-- Postgres does not support removing enum values. diff --git a/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.up.sql b/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.up.sql new file mode 100644 index 0000000000..0405867a29 --- /dev/null +++ b/coderd/database/migrations/000517_audit_user_ai_budget_override_resource_type.up.sql @@ -0,0 +1,2 @@ +-- Audit log resource type for user AI budget overrides. +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'user_ai_budget_override'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 62eb12a1d2..f823ecfeb5 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -102,6 +102,26 @@ func (b GroupAiBudget) Auditable(groupName string) AuditableGroupAiBudget { } } +// AuditableUserAiBudgetOverride is the audit-log representation of +// UserAiBudgetOverride. It enriches the raw record with the username, the +// attributed group's name, and a human-readable spend limit so audit +// entries can display meaningful values instead of UUIDs and micros. +type AuditableUserAiBudgetOverride struct { + UserAiBudgetOverride + Username string `json:"username"` + GroupName string `json:"group_name"` + SpendLimit string `json:"spend_limit"` +} + +func (o UserAiBudgetOverride) Auditable(username, groupName string) AuditableUserAiBudgetOverride { + return AuditableUserAiBudgetOverride{ + UserAiBudgetOverride: o, + Username: username, + GroupName: groupName, + SpendLimit: fmt.Sprintf("$%.2f", float64(o.SpendLimitMicros)/1_000_000), + } +} + // Auditable returns an object that can be used in audit logs. // Covers both group and group member changes. func (g Group) Auditable(members []GroupMember) AuditableGroup { diff --git a/coderd/database/models.go b/coderd/database/models.go index f7ee4b65d4..10ab50e5dc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3366,6 +3366,7 @@ const ( ResourceTypeGroupAiBudget ResourceType = "group_ai_budget" ResourceTypeUserSkill ResourceType = "user_skill" ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key" + ResourceTypeUserAiBudgetOverride ResourceType = "user_ai_budget_override" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3438,7 +3439,8 @@ func (e ResourceType) Valid() bool { ResourceTypeAIProviderKey, ResourceTypeGroupAiBudget, ResourceTypeUserSkill, - ResourceTypeAIGatewayKey: + ResourceTypeAIGatewayKey, + ResourceTypeUserAiBudgetOverride: return true } return false @@ -3480,6 +3482,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeGroupAiBudget, ResourceTypeUserSkill, ResourceTypeAIGatewayKey, + ResourceTypeUserAiBudgetOverride, } } diff --git a/codersdk/audit.go b/codersdk/audit.go index e58bbb71f7..6193b06810 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -43,16 +43,17 @@ const ( 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" - ResourceTypeChat ResourceType = "chat" - ResourceTypeUserSecret ResourceType = "user_secret" - ResourceTypeUserSkill ResourceType = "user_skill" + 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" ) func (r ResourceType) FriendlyString() string { @@ -121,6 +122,8 @@ func (r ResourceType) FriendlyString() string { return "ai gateway key" case ResourceTypeGroupAIBudget: return "group ai budget" + case ResourceTypeUserAIBudgetOverride: + return "user ai budget override" case ResourceTypeChat: return "chat" case ResourceTypeUserSecret: diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 0916c4550d..e7bc259b73 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -24,6 +24,7 @@ We track the following resources: | Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableGroupAiBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| AuditableUserAiBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
group_acltrue
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index deb1aab657..ef805f7ec0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11102,9 +11102,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 08d64c67d6..7b050584ae 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -18,24 +18,25 @@ import ( // AuditableResources map (below) as our documentation - generated in scripts/auditdocgen/main.go - // depends upon it. var AuditActionMap = map[string][]codersdk.AuditAction{ - "GitSSHKey": {codersdk.AuditActionCreate}, - "Template": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "TemplateVersion": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, - "User": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop}, - "Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "APIKey": {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionRegister, codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, - "Task": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "AiSeatState": {codersdk.AuditActionCreate}, - "AIProvider": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "AIProviderKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, - "AIGatewayKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, - "AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. - "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "GitSSHKey": {codersdk.AuditActionCreate}, + "Template": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "TemplateVersion": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, + "User": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop}, + "Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "APIKey": {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionRegister, codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, + "Task": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "AiSeatState": {codersdk.AuditActionCreate}, + "AIProvider": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "AIProviderKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, + "AIGatewayKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, + "AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "AuditableUserAiBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. + "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, } type Action string @@ -232,6 +233,16 @@ var auditableResourcesTypes = map[any]map[string]Action{ "created_at": ActionIgnore, // Redundant with the audit log's own timestamp. "updated_at": ActionIgnore, // Redundant with the audit log's own timestamp. }, + &database.AuditableUserAiBudgetOverride{}: { + "user_id": ActionIgnore, // Username is already included in the title. + "username": ActionIgnore, // Username is already included in the title. + "group_id": ActionTrack, + "group_name": ActionTrack, + "spend_limit_micros": ActionIgnore, + "spend_limit": ActionTrack, // Track spend_limit, the human-readable version. + "created_at": ActionIgnore, // Redundant with the audit log's own timestamp. + "updated_at": ActionIgnore, // Redundant with the audit log's own timestamp. + }, &database.APIKey{}: { "id": ActionIgnore, "hashed_secret": ActionIgnore, diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 8a220760de..cb4171ee2b 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/searchquery" @@ -872,9 +873,10 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque return } - // Look up the group first so a missing or forbidden group_id returns - // 404, distinct from the 400 "not a member" case handled below. - if _, err := api.Database.GetGroupByID(ctx, req.GroupID); err != nil { + // Look up the new group first so a missing or forbidden group_id + // returns 404. We also need the group for the audit log. + newGroup, err := api.Database.GetGroupByID(ctx, req.GroupID) + if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -884,6 +886,39 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque return } + auditor := api.AGPL.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.AuditableUserAiBudgetOverride](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: newGroup.OrganizationID, + }) + defer commitAudit() + + // Capture the existing override (if any) so the audit log records the + // before-state. An absent row leaves aReq.Old as the zero value. + oldOverride, overrideErr := api.Database.GetUserAIBudgetOverride(ctx, user.ID) + if overrideErr != nil && !errors.Is(overrideErr, sql.ErrNoRows) { + api.Logger.Error(ctx, "fetch existing user AI budget override for audit", slog.Error(overrideErr)) + httpapi.InternalServerError(rw, overrideErr) + return + } + var oldGroupName string + if overrideErr == nil { + // This lookup exists only to record the old group's name in the audit + // diff. Use a system context so it does not add a read requirement on + // the old group that the upsert itself does not impose. + oldGroup, groupErr := api.Database.GetGroupByID(dbauthz.AsSystemRestricted(ctx), oldOverride.GroupID) //nolint:gocritic // see above + if groupErr != nil { + api.Logger.Error(ctx, "fetch old group for user AI budget override audit", slog.Error(groupErr)) + httpapi.InternalServerError(rw, groupErr) + return + } + oldGroupName = oldGroup.Name + } + aReq.Old = oldOverride.Auditable(user.Username, oldGroupName) + override, err := api.Database.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{ UserID: user.ID, GroupID: req.GroupID, @@ -911,6 +946,7 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque httpapi.InternalServerError(rw, err) return } + aReq.New = override.Auditable(user.Username, newGroup.Name) httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override)) } @@ -926,7 +962,36 @@ func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque ctx := r.Context() user := httpmw.UserParam(r) - _, err := api.Database.DeleteUserAIBudgetOverride(ctx, user.ID) + // Fetch the existing override first for audit purposes. + userOverride, err := api.Database.GetUserAIBudgetOverride(ctx, user.ID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "fetch user AI budget override for delete", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + group, err := api.Database.GetGroupByID(ctx, userOverride.GroupID) + if err != nil { + api.Logger.Error(ctx, "get group for user AI budget override delete audit", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + auditor := api.AGPL.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.AuditableUserAiBudgetOverride](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + OrganizationID: group.OrganizationID, + }) + defer commitAudit() + + _, err = api.Database.DeleteUserAIBudgetOverride(ctx, user.ID) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -936,6 +1001,10 @@ func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque httpapi.InternalServerError(rw, err) return } + // Populate the audit snapshot only after delete succeeds. Setting + // it earlier would record a phantom entry if delete races a + // concurrent delete and returns 404. + aReq.Old = userOverride.Auditable(user.Username, group.Name) rw.WriteHeader(http.StatusNoContent) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 1faadd1f53..b239cce860 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -3087,6 +3087,231 @@ func TestUserAIBudgetOverride(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) + + t.Run("Audit/CreatesAndDeletes", func(t *testing.T) { + t.Parallel() + + db, adminClient, owner, targetUser := setupUserAIBudgetOverrideAuditTest(t) + + ctx := testutil.Context(t, testutil.WaitLong) + group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "override-audit", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + // Upsert (create-or-update) emits an AuditActionWrite entry. + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + // Delete emits an AuditActionDelete entry against the same resource. + require.NoError(t, adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID)) + + rows, err := db.GetAuditLogsOffset( + ctx, + database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeUserAiBudgetOverride), + LimitOpt: 10, + }, + ) + require.NoError(t, err) + require.Len(t, rows, 2, "expected one upsert and one delete audit entry") + // GetAuditLogsOffset returns entries sorted by time in descending order. + upsertLog := rows[1].AuditLog + deleteLog := rows[0].AuditLog + + require.Equal(t, database.AuditActionWrite, upsertLog.Action) + require.Equal(t, targetUser.ID, upsertLog.ResourceID) + require.Equal(t, database.ResourceTypeUserAiBudgetOverride, upsertLog.ResourceType) + require.Equal(t, targetUser.Username, upsertLog.ResourceTarget) + require.Equal(t, owner.OrganizationID, upsertLog.OrganizationID) + + var upsertDiff audit.Map + require.NoError(t, json.Unmarshal(upsertLog.Diff, &upsertDiff)) + require.Contains(t, upsertDiff, "spend_limit") + require.Equal(t, "$0.00", upsertDiff["spend_limit"].Old) + require.Equal(t, "$500.00", upsertDiff["spend_limit"].New) + require.Contains(t, upsertDiff, "group_name") + require.Equal(t, "", upsertDiff["group_name"].Old) + require.Equal(t, group.Name, upsertDiff["group_name"].New) + require.Contains(t, upsertDiff, "group_id") + require.Equal(t, "", upsertDiff["group_id"].Old) + require.Equal(t, group.ID.String(), upsertDiff["group_id"].New) + // Fields marked ActionIgnore must not appear in the diff. + require.NotContains(t, upsertDiff, "user_id") + require.NotContains(t, upsertDiff, "username") + require.NotContains(t, upsertDiff, "spend_limit_micros") + require.NotContains(t, upsertDiff, "created_at") + require.NotContains(t, upsertDiff, "updated_at") + + require.Equal(t, database.AuditActionDelete, deleteLog.Action) + require.Equal(t, targetUser.ID, deleteLog.ResourceID) + require.Equal(t, database.ResourceTypeUserAiBudgetOverride, deleteLog.ResourceType) + require.Equal(t, targetUser.Username, deleteLog.ResourceTarget) + require.Equal(t, owner.OrganizationID, deleteLog.OrganizationID) + + var deleteDiff audit.Map + require.NoError(t, json.Unmarshal(deleteLog.Diff, &deleteDiff)) + require.Contains(t, deleteDiff, "spend_limit") + require.Equal(t, "$500.00", deleteDiff["spend_limit"].Old) + require.Equal(t, "", deleteDiff["spend_limit"].New) + require.Contains(t, deleteDiff, "group_name") + require.Equal(t, group.Name, deleteDiff["group_name"].Old) + require.Equal(t, "", deleteDiff["group_name"].New) + require.Contains(t, deleteDiff, "group_id") + require.Equal(t, group.ID.String(), deleteDiff["group_id"].Old) + require.Equal(t, "", deleteDiff["group_id"].New) + }) + + t.Run("Audit/DeleteAbsentEmitsNoEntry", func(t *testing.T) { + t.Parallel() + + // Deleting an override that does not exist must not emit an audit log entry. + db, adminClient, _, targetUser := setupUserAIBudgetOverrideAuditTest(t) + + ctx := testutil.Context(t, testutil.WaitLong) + + err := adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + + rows, err := db.GetAuditLogsOffset( + ctx, + database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeUserAiBudgetOverride), + LimitOpt: 10, + }, + ) + require.NoError(t, err) + require.Empty(t, rows, "no audit entry expected when delete returns 404") + }) + + t.Run("Audit/UpsertEverything", func(t *testing.T) { + t.Parallel() + + // A second upsert that reassigns the attributed group and changes + // the spend limit must record the prior state as the audit + // before-state. + db, adminClient, owner, targetUser := setupUserAIBudgetOverrideAuditTest(t) + + ctx := testutil.Context(t, testutil.WaitLong) + groupA, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "reassign-audit-a", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, groupA.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + groupB, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "reassign-audit-b", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, groupB.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + // First upsert: create the override attributed to groupA. + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupA.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + // Second upsert: reassign to groupB and raise the spend limit. + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupB.ID, + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + + rows, err := db.GetAuditLogsOffset( + ctx, + database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeUserAiBudgetOverride), + LimitOpt: 10, + }, + ) + require.NoError(t, err) + require.Len(t, rows, 2, "expected one create and one update audit entry") + // GetAuditLogsOffset returns entries sorted by time in descending order. + updateLog := rows[0].AuditLog + + var updateDiff audit.Map + require.NoError(t, json.Unmarshal(updateLog.Diff, &updateDiff)) + require.Contains(t, updateDiff, "group_name") + require.Equal(t, groupA.Name, updateDiff["group_name"].Old) + require.Equal(t, groupB.Name, updateDiff["group_name"].New) + require.Contains(t, updateDiff, "group_id") + require.Equal(t, groupA.ID.String(), updateDiff["group_id"].Old) + require.Equal(t, groupB.ID.String(), updateDiff["group_id"].New) + require.Contains(t, updateDiff, "spend_limit") + require.Equal(t, "$500.00", updateDiff["spend_limit"].Old) + require.Equal(t, "$1000.00", updateDiff["spend_limit"].New) + }) + + t.Run("Audit/UpsertSpendLimit", func(t *testing.T) { + t.Parallel() + + // A second upsert that keeps the same group and only changes the + // spend limit must produce a diff that contains spend_limit and omits + // the unchanged group_name and group_id. + db, adminClient, owner, targetUser := setupUserAIBudgetOverrideAuditTest(t) + + ctx := testutil.Context(t, testutil.WaitLong) + group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "spend-only-audit", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + // First upsert: create the override attributed to the group. + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + // Second upsert: keep the same group, raise only the spend limit. + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + + rows, err := db.GetAuditLogsOffset( + ctx, + database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeUserAiBudgetOverride), + LimitOpt: 10, + }, + ) + require.NoError(t, err) + require.Len(t, rows, 2, "expected one create and one update audit entry") + // GetAuditLogsOffset returns entries sorted by time in descending order. + updateLog := rows[0].AuditLog + + var updateDiff audit.Map + require.NoError(t, json.Unmarshal(updateLog.Diff, &updateDiff)) + require.Contains(t, updateDiff, "spend_limit") + require.Equal(t, "$500.00", updateDiff["spend_limit"].Old) + require.Equal(t, "$1000.00", updateDiff["spend_limit"].New) + require.NotContains(t, updateDiff, "group_name") + require.NotContains(t, updateDiff, "group_id") + require.NotContains(t, updateDiff, "spend_limit_micros") + }) } // TestUserAIBudgetOverrideRoleAccess verifies the authz matrix for the roles @@ -3312,6 +3537,41 @@ func setupUserAIBudgetOverrideTest(t *testing.T) (adminClient *codersdk.Client, return adminClient, targetUser, g } +// setupUserAIBudgetOverrideAuditTest builds a deployment wired with the +// enterprise auditor (the mock auditor does not compute diffs) so audit +// entries can be read straight from the audit_logs table. +func setupUserAIBudgetOverrideAuditTest(t *testing.T) (database.Store, *codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.User) { + t.Helper() + + db, ps := dbtestutil.NewDB(t) + auditor := entaudit.NewAuditor( + db, + entaudit.DefaultFilter, + backends.NewPostgres(db, true), + ) + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + AuditLogging: true, + Options: &coderdtest.Options{ + DeploymentValues: dv, + Database: db, + Pubsub: ps, + Auditor: auditor, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + codersdk.FeatureAuditLog: 1, + }, + }, + }) + adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + return db, adminClient, owner, targetUser +} + // setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it. func setupGroupAIBudgetTest(t *testing.T) (adminClient *codersdk.Client, group codersdk.Group) { t.Helper() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 50e67e7342..a734f9a7d8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7182,6 +7182,7 @@ export type ResourceType = | "template" | "template_version" | "user" + | "user_ai_budget_override" | "user_secret" | "user_skill" | "workspace" @@ -7218,6 +7219,7 @@ export const ResourceTypes: ResourceType[] = [ "template", "template_version", "user", + "user_ai_budget_override", "user_secret", "user_skill", "workspace",