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:
Yevhenii Shcherbina
2026-05-18 15:17:20 -04:00
committed by GitHub
parent 385146000b
commit 2732378da2
17 changed files with 223 additions and 27 deletions
+2
View File
@@ -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"
]
+2
View File
@@ -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"
]
+12
View File
@@ -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 ""
}
+1
View File
@@ -37,6 +37,7 @@ type Auditable interface {
database.AIProvider |
database.AIProviderKey |
database.Chat |
database.AuditableGroupAiBudget |
database.UserSecret
}
+9
View File
@@ -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).
+2 -1
View File
@@ -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';
+19
View File
@@ -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 {
+4 -1
View File
@@ -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,
}
}
+3
View File
@@ -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:
+1
View File
@@ -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> |
+3 -3
View File
@@ -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
View File
@@ -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,
+42 -7
View File
@@ -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)
}
+94
View File
@@ -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.
+2
View File
@@ -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",