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 |
| Field | Tracked |
| | avatar_url | true |
| chat_spend_limit_micros | true |
| display_name | true |
| id | true |
| members | true |
| name | true |
| organization_id | false |
| quota_allowance | true |
| source | false |
|
| AuditableGroupAiBudget
write, delete | | Field | Tracked |
| | created_at | false |
| group_id | false |
| group_name | false |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
|
| AuditableOrganizationMember
| | Field | Tracked |
| | created_at | true |
| organization_id | false |
| roles | true |
| updated_at | true |
| user_id | true |
| username | true |
|
+| AuditableUserAiBudgetOverride
write, delete | | Field | Tracked |
| | created_at | false |
| group_id | true |
| group_name | true |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
| user_id | false |
| username | false |
|
| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| created_at | false |
| dynamic_tools | false |
| group_acl | true |
| heartbeat_at | false |
| id | true |
| labels | true |
| last_error | false |
| last_injected_context | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| owner_name | false |
| owner_username | false |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| root_chat_id | false |
| started_at | false |
| status | false |
| title | true |
| updated_at | false |
| user_acl | true |
| worker_id | false |
| workspace_id | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
| GitSSHKey
create | | Field | Tracked |
| | created_at | false |
| private_key | true |
| private_key_key_id | false |
| public_key | true |
| updated_at | false |
| user_id | true |
|
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",