diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 60a0415e43..b3ec4392a3 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -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"
]
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index e2f5e16234..a3416d9ce3 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -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"
]
diff --git a/coderd/audit.go b/coderd/audit.go
index b4070622eb..661019d063 100644
--- a/coderd/audit.go
+++ b/coderd/audit.go
@@ -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 ""
}
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 3c6df99574..b4382c79a3 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -37,6 +37,7 @@ type Auditable interface {
database.AIProvider |
database.AIProviderKey |
database.Chat |
+ database.AuditableGroupAiBudget |
database.UserSecret
}
diff --git a/coderd/audit/request.go b/coderd/audit/request.go
index 25da232c92..845265f229 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -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).
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 40e66fb044..f30b6caa73 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -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 (
diff --git a/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.down.sql b/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.down.sql
new file mode 100644
index 0000000000..d952e380f3
--- /dev/null
+++ b/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.down.sql
@@ -0,0 +1 @@
+-- Postgres does not support removing enum values.
diff --git a/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.up.sql b/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.up.sql
new file mode 100644
index 0000000000..c616a592fe
--- /dev/null
+++ b/coderd/database/migrations/000500_audit_group_ai_budget_resource_type.up.sql
@@ -0,0 +1,2 @@
+-- Audit log resource type for group AI budgets.
+ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'group_ai_budget';
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index fde2d38c00..308a33baf0 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -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 {
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 5027c557ea..7af3e2b5cd 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -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,
}
}
diff --git a/codersdk/audit.go b/codersdk/audit.go
index 912e1a8f85..253a09d99b 100644
--- a/codersdk/audit.go
+++ b/codersdk/audit.go
@@ -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:
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index a126aac611..9234d87982 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -21,6 +21,7 @@ We track the following resources:
| AiSeatState
create |
| Field | Tracked |
| | first_used_at | true |
| last_event_description | true |
| last_event_type | true |
| last_used_at | false |
| updated_at | false |
| user_id | true |
|
| AuditOAuthConvertState
| | Field | Tracked |
| | created_at | true |
| expires_at | true |
| from_login_type | true |
| to_login_type | true |
| user_id | true |
|
| Group
create, write, delete | | Field | Tracked |
| | avatar_url | true |
| chat_spend_limit_micros | true |
| display_name | true |
| id | true |
| members | true |
| name | true |
| organization_id | false |
| quota_allowance | true |
| source | false |
|
+| AuditableGroupAiBudget
write, delete | | Field | Tracked |
| | created_at | false |
| group_id | false |
| group_name | false |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
|
| AuditableOrganizationMember
| | Field | Tracked |
| | created_at | true |
| organization_id | false |
| roles | true |
| updated_at | true |
| user_id | true |
| username | true |
|
| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| created_at | false |
| dynamic_tools | false |
| heartbeat_at | false |
| id | true |
| labels | true |
| last_error | false |
| last_injected_context | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| owner_name | false |
| owner_username | false |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| root_chat_id | false |
| started_at | false |
| status | false |
| title | true |
| updated_at | false |
| worker_id | false |
| workspace_id | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 87392a67ca..436395a88d 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -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
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 3dc614a529..2bdf20820a 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -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,
diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go
index 3f27ff5169..f633054288 100644
--- a/enterprise/coderd/aibridge.go
+++ b/enterprise/coderd/aibridge.go
@@ -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)
}
diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go
index e4015b75b7..6e7caedec4 100644
--- a/enterprise/coderd/aibridge_test.go
+++ b/enterprise/coderd/aibridge_test.go
@@ -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.
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index d756d9b103..bf759bc41b 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -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",