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 | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableGroupAiBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| | Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
worker_idfalse
workspace_idtrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| 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",