mirror of
https://github.com/coder/coder.git
synced 2026-10-25 09:42:55 +08:00
feat: audit user AI budget override mutations (#25745)
Relates to https://linear.app/codercom/issue/AIGOV-285/add-user-budget-overrides-table-and-crud-api Adds audit-log support for `user_ai_budget_override` mutations. Without it, an admin could quietly change a user's per-user spend cap (e.g. from `$500` to `$50`), reassign it to a different group, or delete it entirely with no record of who did it. Both write (`create-or-update`) and delete actions now generate audit log entries. Unlike group AI budgets, which only track `spend_limit`, overrides also track `group_name`: an override can be reassigned to a different attributed group, so that change needs to show up in the diff. The raw `spend_limit_micros`, IDs, and timestamps are ignored in favor of the human-readable `spend_limit` and `group_name`. Depends on #25439. ## Screenshot <img width="1343" height="514" alt="image" src="https://github.com/user-attachments/assets/aee30f58-6e81-435e-9bca-5bc98f49d8d3" />
This commit is contained in:
Generated
+2
@@ -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"
|
||||
|
||||
Generated
+2
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type Auditable interface {
|
||||
database.AIGatewayKey |
|
||||
database.Chat |
|
||||
database.AuditableGroupAiBudget |
|
||||
database.AuditableUserAiBudgetOverride |
|
||||
database.UserSecret |
|
||||
database.UserSkill
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Generated
+2
-1
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Postgres does not support removing enum values.
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
Generated
+4
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-10
@@ -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:
|
||||
|
||||
@@ -24,6 +24,7 @@ We track the following resources:
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>true</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||
| AuditableGroupAiBudget<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>group_id</td><td>false</td></tr><tr><td>group_name</td><td>false</td></tr><tr><td>spend_limit</td><td>true</td></tr><tr><td>spend_limit_micros</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| AuditableUserAiBudgetOverride<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>group_id</td><td>true</td></tr><tr><td>group_name</td><td>true</td></tr><tr><td>spend_limit</td><td>true</td></tr><tr><td>spend_limit_micros</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr><tr><td>username</td><td>false</td></tr></tbody></table> |
|
||||
| Chat<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>agent_id</td><td>false</td></tr><tr><td>archived</td><td>true</td></tr><tr><td>build_id</td><td>false</td></tr><tr><td>client_type</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamic_tools</td><td>false</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>heartbeat_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>labels</td><td>true</td></tr><tr><td>last_error</td><td>false</td></tr><tr><td>last_injected_context</td><td>false</td></tr><tr><td>last_model_config_id</td><td>false</td></tr><tr><td>last_read_message_id</td><td>false</td></tr><tr><td>last_turn_summary</td><td>false</td></tr><tr><td>mcp_server_ids</td><td>true</td></tr><tr><td>mode</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>owner_name</td><td>false</td></tr><tr><td>owner_username</td><td>false</td></tr><tr><td>parent_chat_id</td><td>false</td></tr><tr><td>pin_order</td><td>true</td></tr><tr><td>plan_mode</td><td>false</td></tr><tr><td>root_chat_id</td><td>false</td></tr><tr><td>started_at</td><td>false</td></tr><tr><td>status</td><td>false</td></tr><tr><td>title</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr><tr><td>worker_id</td><td>false</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>private_key_key_id</td><td>false</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
+29
-18
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user