mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: audit group AI budget mutations (#25374)
Relates to https://linear.app/codercom/issue/AIGOV-284/add-group-budgets-table-and-crud-api Adds audit-log support for `group_ai_budget` mutations. Without it, an admin could silently lower a spend limit from `$500` to `$50` or delete a budget entirely, with no record of who performed the action. Both write (`create-or-update`) and delete actions now produce audit log entries, including before/after diffs for `spend_limit_micros`. Depends on #25203. ## Old Version <img width="1340" height="456" alt="image" src="https://github.com/user-attachments/assets/e9ff52fb-a905-4aef-a4ee-7cdc58e68b75" /> ## New Version (see https://github.com/coder/coder/pull/25374/changes/9d22833de87cc106c24142c1d471a3f71872bf67) <img width="1347" height="496" alt="image" src="https://github.com/user-attachments/assets/1b9bbfa1-f86d-48e3-a0b1-266eb76f851f" />
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user