mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: audit group AI budget mutations (#25374)
Relates to https://linear.app/codercom/issue/AIGOV-284/add-group-budgets-table-and-crud-api Adds audit-log support for `group_ai_budget` mutations. Without it, an admin could silently lower a spend limit from `$500` to `$50` or delete a budget entirely, with no record of who performed the action. Both write (`create-or-update`) and delete actions now produce audit log entries, including before/after diffs for `spend_limit_micros`. Depends on #25203. ## Old Version <img width="1340" height="456" alt="image" src="https://github.com/user-attachments/assets/e9ff52fb-a905-4aef-a4ee-7cdc58e68b75" /> ## New Version (see https://github.com/coder/coder/pull/25374/changes/9d22833de87cc106c24142c1d471a3f71872bf67) <img width="1347" height="496" alt="image" src="https://github.com/user-attachments/assets/1b9bbfa1-f86d-48e3-a0b1-266eb76f851f" />
This commit is contained in:
Generated
+2
@@ -21804,6 +21804,7 @@ const docTemplate = `{
|
||||
"ai_seat",
|
||||
"ai_provider",
|
||||
"ai_provider_key",
|
||||
"group_ai_budget",
|
||||
"chat",
|
||||
"user_secret"
|
||||
],
|
||||
@@ -21837,6 +21838,7 @@ const docTemplate = `{
|
||||
"ResourceTypeAISeat",
|
||||
"ResourceTypeAIProvider",
|
||||
"ResourceTypeAIProviderKey",
|
||||
"ResourceTypeGroupAIBudget",
|
||||
"ResourceTypeChat",
|
||||
"ResourceTypeUserSecret"
|
||||
]
|
||||
|
||||
Generated
+2
@@ -20006,6 +20006,7 @@
|
||||
"ai_seat",
|
||||
"ai_provider",
|
||||
"ai_provider_key",
|
||||
"group_ai_budget",
|
||||
"chat",
|
||||
"user_secret"
|
||||
],
|
||||
@@ -20039,6 +20040,7 @@
|
||||
"ResourceTypeAISeat",
|
||||
"ResourceTypeAIProvider",
|
||||
"ResourceTypeAIProviderKey",
|
||||
"ResourceTypeGroupAIBudget",
|
||||
"ResourceTypeChat",
|
||||
"ResourceTypeUserSecret"
|
||||
]
|
||||
|
||||
@@ -552,6 +552,18 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
// TODO(PLAT-102): point at the user secrets management page once
|
||||
// it ships. Until then, the audit row links nowhere.
|
||||
return ""
|
||||
case database.ResourceTypeGroupAiBudget:
|
||||
// The resource_id is the group's UUID; link to the group's
|
||||
// settings page.
|
||||
group, err := api.Database.GetGroupByID(ctx, alog.AuditLog.ResourceID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/organizations/%s/groups/%s", org.Name, group.Name)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type Auditable interface {
|
||||
database.AIProvider |
|
||||
database.AIProviderKey |
|
||||
database.Chat |
|
||||
database.AuditableGroupAiBudget |
|
||||
database.UserSecret
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ func ResourceTarget[T Auditable](tgt T) string {
|
||||
// provider's UUID so the row can be correlated back to its
|
||||
// provider in the audit UI.
|
||||
return typed.ProviderID.String()
|
||||
case database.AuditableGroupAiBudget:
|
||||
return typed.GroupName
|
||||
case database.Chat:
|
||||
// Chat titles can contain sensitive content (secrets, internal
|
||||
// project names), so we use a short UUID prefix as a display
|
||||
@@ -221,6 +223,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
|
||||
return typed.ID
|
||||
case database.AIProviderKey:
|
||||
return typed.ID
|
||||
case database.AuditableGroupAiBudget:
|
||||
return typed.GroupID
|
||||
case database.Chat:
|
||||
return typed.ID
|
||||
case database.UserSecret:
|
||||
@@ -286,6 +290,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
|
||||
return database.ResourceTypeAiProvider
|
||||
case database.AIProviderKey:
|
||||
return database.ResourceTypeAiProviderKey
|
||||
case database.AuditableGroupAiBudget:
|
||||
return database.ResourceTypeGroupAiBudget
|
||||
case database.Chat:
|
||||
return database.ResourceTypeChat
|
||||
case database.UserSecret:
|
||||
@@ -356,6 +362,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
|
||||
case database.AIProviderKey:
|
||||
// AI provider keys are deployment-scoped, not org-scoped.
|
||||
return false
|
||||
case database.AuditableGroupAiBudget:
|
||||
// Group AI budgets are org-scoped through their parent group.
|
||||
return true
|
||||
case database.Chat:
|
||||
// Chats always have a non-null organization_id (since
|
||||
// migration 000467).
|
||||
|
||||
Generated
+2
-1
@@ -551,7 +551,8 @@ CREATE TYPE resource_type AS ENUM (
|
||||
'chat',
|
||||
'user_secret',
|
||||
'ai_provider',
|
||||
'ai_provider_key'
|
||||
'ai_provider_key',
|
||||
'group_ai_budget'
|
||||
);
|
||||
|
||||
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 group AI budgets.
|
||||
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'group_ai_budget';
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -83,6 +84,24 @@ type AuditableGroup struct {
|
||||
Members []GroupMemberTable `json:"members"`
|
||||
}
|
||||
|
||||
// AuditableGroupAiBudget is the audit-log representation of GroupAiBudget.
|
||||
// It enriches the raw record with the group's name and a human-readable
|
||||
// spend limit so audit entries can display meaningful values instead of
|
||||
// UUIDs and micros.
|
||||
type AuditableGroupAiBudget struct {
|
||||
GroupAiBudget
|
||||
GroupName string `json:"group_name"`
|
||||
SpendLimit string `json:"spend_limit"`
|
||||
}
|
||||
|
||||
func (b GroupAiBudget) Auditable(groupName string) AuditableGroupAiBudget {
|
||||
return AuditableGroupAiBudget{
|
||||
GroupAiBudget: b,
|
||||
GroupName: groupName,
|
||||
SpendLimit: fmt.Sprintf("$%.2f", float64(b.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 {
|
||||
|
||||
@@ -3318,6 +3318,7 @@ const (
|
||||
ResourceTypeUserSecret ResourceType = "user_secret"
|
||||
ResourceTypeAiProvider ResourceType = "ai_provider"
|
||||
ResourceTypeAiProviderKey ResourceType = "ai_provider_key"
|
||||
ResourceTypeGroupAiBudget ResourceType = "group_ai_budget"
|
||||
)
|
||||
|
||||
func (e *ResourceType) Scan(src interface{}) error {
|
||||
@@ -3387,7 +3388,8 @@ func (e ResourceType) Valid() bool {
|
||||
ResourceTypeChat,
|
||||
ResourceTypeUserSecret,
|
||||
ResourceTypeAiProvider,
|
||||
ResourceTypeAiProviderKey:
|
||||
ResourceTypeAiProviderKey,
|
||||
ResourceTypeGroupAiBudget:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -3426,6 +3428,7 @@ func AllResourceTypeValues() []ResourceType {
|
||||
ResourceTypeUserSecret,
|
||||
ResourceTypeAiProvider,
|
||||
ResourceTypeAiProviderKey,
|
||||
ResourceTypeGroupAiBudget,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user