mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
committed by
GitHub
parent
385146000b
commit
2732378da2
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const (
|
||||
ResourceTypeAISeat ResourceType = "ai_seat"
|
||||
ResourceTypeAIProvider ResourceType = "ai_provider"
|
||||
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
|
||||
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
|
||||
ResourceTypeChat ResourceType = "chat"
|
||||
ResourceTypeUserSecret ResourceType = "user_secret"
|
||||
)
|
||||
@@ -114,6 +115,8 @@ func (r ResourceType) FriendlyString() string {
|
||||
return "ai provider"
|
||||
case ResourceTypeAIProviderKey:
|
||||
return "ai provider key"
|
||||
case ResourceTypeGroupAIBudget:
|
||||
return "group ai budget"
|
||||
case ResourceTypeChat:
|
||||
return "chat"
|
||||
case ResourceTypeUserSecret:
|
||||
|
||||
@@ -21,6 +21,7 @@ We track the following resources:
|
||||
| AiSeatState<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>first_used_at</td><td>true</td></tr><tr><td>last_event_description</td><td>true</td></tr><tr><td>last_event_type</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AuditOAuthConvertState<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>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| 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> |
|
||||
| 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>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>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> |
|
||||
|
||||
Generated
+3
-3
@@ -10718,9 +10718,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `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`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` |
|
||||
| Value(s) |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `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`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` |
|
||||
|
||||
## codersdk.Response
|
||||
|
||||
|
||||
+24
-15
@@ -18,21 +18,22 @@ 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},
|
||||
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
|
||||
"UserSecret": {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},
|
||||
"AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
|
||||
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
}
|
||||
|
||||
type Action string
|
||||
@@ -220,6 +221,14 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"source": ActionIgnore,
|
||||
"chat_spend_limit_micros": ActionTrack,
|
||||
},
|
||||
&database.AuditableGroupAiBudget{}: {
|
||||
"group_id": ActionIgnore, // Group name is already included in the title.
|
||||
"spend_limit_micros": ActionIgnore,
|
||||
"spend_limit": ActionTrack, // Track spend_limit, which is the human-readable version.
|
||||
"group_name": ActionIgnore, // Group name is already included in the title.
|
||||
"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,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
|
||||
"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/httpapi"
|
||||
@@ -735,15 +736,36 @@ func (api *API) groupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {object} codersdk.GroupAIBudget
|
||||
// @Router /api/v2/groups/{group}/ai/budget [put]
|
||||
func (api *API) upsertGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
group := httpmw.GroupParam(r)
|
||||
var (
|
||||
ctx = r.Context()
|
||||
group = httpmw.GroupParam(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AuditableGroupAiBudget](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
OrganizationID: group.OrganizationID,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
var req codersdk.UpsertGroupAIBudgetRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
budget, err := api.Database.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
|
||||
// Capture the existing budget (if any) so the audit log records the
|
||||
// before-state. An absent row leaves aReq.Old as the zero value.
|
||||
oldBudget, err := api.Database.GetGroupAIBudget(ctx, group.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
api.Logger.Error(ctx, "fetch existing group AI budget for audit", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
aReq.Old = oldBudget.Auditable(group.Name)
|
||||
|
||||
newBudget, err := api.Database.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: req.SpendLimitMicros,
|
||||
})
|
||||
@@ -756,8 +778,9 @@ func (api *API) upsertGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
aReq.New = newBudget.Auditable(group.Name)
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.GroupAIBudget(budget))
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.GroupAIBudget(newBudget))
|
||||
}
|
||||
|
||||
// @Summary Delete group AI budget
|
||||
@@ -768,10 +791,21 @@ func (api *API) upsertGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Success 204
|
||||
// @Router /api/v2/groups/{group}/ai/budget [delete]
|
||||
func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
group := httpmw.GroupParam(r)
|
||||
var (
|
||||
ctx = r.Context()
|
||||
group = httpmw.GroupParam(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AuditableGroupAiBudget](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionDelete,
|
||||
OrganizationID: group.OrganizationID,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
_, err := api.Database.DeleteGroupAIBudget(ctx, group.ID)
|
||||
deleted, err := api.Database.DeleteGroupAIBudget(ctx, group.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
@@ -781,6 +815,7 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
aReq.Old = deleted.Auditable(group.Name)
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,18 @@ import (
|
||||
|
||||
aiblib "github.com/coder/coder/v2/aibridge"
|
||||
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
entaudit "github.com/coder/coder/v2/enterprise/audit"
|
||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -2775,6 +2779,96 @@ func TestGroupAIBudget(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 500_000_000, got.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Audit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The enterprise auditor is needed because the mock auditor does
|
||||
// not compute diffs. We read straight from the audit_logs table to
|
||||
// validate the diff content.
|
||||
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())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "budget-audit",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upsert (create-or-update) emits an AuditActionWrite entry.
|
||||
_, err = adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete emits an AuditActionDelete entry against the same resource.
|
||||
require.NoError(t, adminClient.DeleteGroupAIBudget(ctx, group.ID))
|
||||
rows, err := db.GetAuditLogsOffset(
|
||||
ctx,
|
||||
database.GetAuditLogsOffsetParams{
|
||||
ResourceType: string(database.ResourceTypeGroupAiBudget),
|
||||
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, group.ID, upsertLog.ResourceID)
|
||||
require.Equal(t, database.ResourceTypeGroupAiBudget, upsertLog.ResourceType)
|
||||
require.Equal(t, group.Name, 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)
|
||||
// Fields marked ActionIgnore must not appear in the diff.
|
||||
require.NotContains(t, upsertDiff, "group_id")
|
||||
require.NotContains(t, upsertDiff, "group_name")
|
||||
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, group.ID, deleteLog.ResourceID)
|
||||
require.Equal(t, database.ResourceTypeGroupAiBudget, deleteLog.ResourceType)
|
||||
require.Equal(t, group.Name, 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)
|
||||
})
|
||||
}
|
||||
|
||||
// setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it.
|
||||
|
||||
Generated
+2
@@ -6838,6 +6838,7 @@ export type ResourceType =
|
||||
| "custom_role"
|
||||
| "git_ssh_key"
|
||||
| "group"
|
||||
| "group_ai_budget"
|
||||
| "health_settings"
|
||||
| "idp_sync_settings_group"
|
||||
| "idp_sync_settings_organization"
|
||||
@@ -6871,6 +6872,7 @@ export const ResourceTypes: ResourceType[] = [
|
||||
"custom_role",
|
||||
"git_ssh_key",
|
||||
"group",
|
||||
"group_ai_budget",
|
||||
"health_settings",
|
||||
"idp_sync_settings_group",
|
||||
"idp_sync_settings_organization",
|
||||
|
||||
Reference in New Issue
Block a user