mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add user AI budget override endpoints (#25439)
Implements https://linear.app/codercom/issue/AIGOV-285 Follow the structure established in https://github.com/coder/coder/pull/25203 ## Summary Adds the `user_ai_budget_overrides` table and CRUD API at `/api/v2/users/{user}/ai/budget`. An override sets a custom per-user spend cap that supersedes group-budget resolution, attributing spend to a specific group. ## Schema ```sql CREATE TABLE user_ai_budget_overrides ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ## Membership lifecycle The membership invariant — a user must be a member of the attributed group, including when that group is "Everyone" — would naturally be expressed as a composite FK on `(user_id, group_id) → group_members_expanded(user_id, group_id)`. PostgreSQL doesn't allow foreign keys to reference views, so enforcement is split across two mechanisms: - **Write-time check.** A CHECK constraint on the table (`user_ai_budget_overrides_must_be_group_member`) calls a `STABLE` function `is_group_member(user_id, group_id)` that queries `group_members_expanded`. The view surfaces both regular group memberships and the implicit "Everyone" group memberships from `organization_members`. Any INSERT or UPDATE that violates the predicate is rejected with a Postgres `check_violation`, which the handler maps to a 400. `is_group_member` is defined as a general predicate, reusable by any future table that needs the same check. - **Cascade on removal.** Two `BEFORE DELETE` triggers handle membership loss: - `trigger_delete_user_ai_budget_overrides_on_group_member_delete` on `group_members` — covers regular group removals (admin action, OIDC sync). - `trigger_delete_user_ai_budget_overrides_on_org_member_delete` on `organization_members` — covers the "Everyone" group, whose membership lives in `organization_members`. The single-column FKs on `users(id)` and `groups(id)` remain to cascade on user or group deletion (those paths don't pass through `group_members`). ## Authorization The dbauthz layer gates each operation against the `User` and (for writes) `Group` resources: | Operation | User resource | Group resource | |-----------|----------------|----------------| | `GET` | `ActionRead` | — | | `PUT` | `ActionUpdate` | `ActionUpdate` | | `DELETE` | `ActionUpdate` | `ActionUpdate` | For `DELETE`, the dbauthz layer fetches the existing override first to learn the attributed `group_id`, then runs both checks. ### Role matrix | Role | GET | PUT | DELETE | |--------------|-----|-----|--------| | Owner | ✅ | ✅ | ✅ | | UserAdmin | ✅ | ✅ | ✅ | | OrgAdmin | ✅ | ❌ | ❌ | | OrgUserAdmin | ✅ | ❌ | ❌ | Internal discussion: https://codercom.slack.com/archives/C096PFVBZKN/p1779392747885359 ## Audit logs Audit logs will be addressed in a follow-up PR.
This commit is contained in:
committed by
GitHub
parent
9448624d2d
commit
1a91d31793
@@ -43,6 +43,11 @@ const (
|
||||
// reference a valid resource in the expected scope.
|
||||
var errInvalidCursor = xerrors.New("invalid pagination cursor")
|
||||
|
||||
// This name is raised by a trigger function with USING CONSTRAINT.
|
||||
// It is not a table CHECK constraint, so dbgen does not emit it in
|
||||
// check_constraint.go.
|
||||
const userAIBudgetOverridesMustBeGroupMemberConstraint database.CheckConstraint = "user_ai_budget_overrides_must_be_group_member"
|
||||
|
||||
// aibridgeHandler handles all aibridged-related endpoints.
|
||||
func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
|
||||
// Build the overload protection middleware chain for the aibridged handler.
|
||||
@@ -821,3 +826,116 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Get user AI budget override
|
||||
// @ID get-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Success 200 {object} codersdk.UserAIBudgetOverride
|
||||
// @Router /api/v2/users/{user}/ai/budget [get]
|
||||
func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
override, err := api.Database.GetUserAIBudgetOverride(ctx, user.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "get user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override))
|
||||
}
|
||||
|
||||
// @Summary Upsert user AI budget override
|
||||
// @ID upsert-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Param request body codersdk.UpsertUserAIBudgetOverrideRequest true "Upsert user AI budget override request"
|
||||
// @Success 200 {object} codersdk.UserAIBudgetOverride
|
||||
// @Router /api/v2/users/{user}/ai/budget [put]
|
||||
func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
var req codersdk.UpsertUserAIBudgetOverrideRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the group first so a missing or forbidden group_id returns
|
||||
// 404, distinct from the 400 "not a member" case handled below.
|
||||
if _, err := api.Database.GetGroupByID(ctx, req.GroupID); err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
api.Logger.Error(ctx, "get group for user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
override, err := api.Database.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{
|
||||
UserID: user.ID,
|
||||
GroupID: req.GroupID,
|
||||
SpendLimitMicros: req.SpendLimitMicros,
|
||||
})
|
||||
// A trigger enforces that the user must be a member of the attributed
|
||||
// group; it raises check_violation with this constraint name. Map
|
||||
// the violation to a structured 400.
|
||||
if database.IsCheckViolation(err, userAIBudgetOverridesMustBeGroupMemberConstraint) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "User is not a member of the referenced group.",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "group_id",
|
||||
Detail: "user must be a member of this group",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "upsert user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override))
|
||||
}
|
||||
|
||||
// @Summary Delete user AI budget override
|
||||
// @ID delete-user-ai-budget-override
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param user path string true "User ID, username, or me"
|
||||
// @Success 204
|
||||
// @Router /api/v2/users/{user}/ai/budget [delete]
|
||||
func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := httpmw.UserParam(r)
|
||||
|
||||
_, err := api.Database.DeleteUserAIBudgetOverride(ctx, user.ID)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "delete user AI budget override", slog.Error(err))
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -2871,6 +2871,447 @@ func TestGroupAIBudget(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserAIBudgetOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Upsert/CreatesAndUpdates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// First upsert creates the override.
|
||||
newOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, targetUser.ID, newOverride.UserID)
|
||||
require.Equal(t, group.ID, newOverride.GroupID)
|
||||
require.EqualValues(t, 500_000_000, newOverride.SpendLimitMicros)
|
||||
|
||||
// Second upsert updates the existing override.
|
||||
updatedOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 1_000_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1_000_000_000, updatedOverride.SpendLimitMicros)
|
||||
|
||||
// GET returns the latest value.
|
||||
currentOverride, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1_000_000_000, currentOverride.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/ReassignsGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, groupA := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// First upsert: attribute spend to groupA.
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: groupA.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create groupB in the same org and add the target user.
|
||||
groupB, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
|
||||
Name: "reassign-test-group-b",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = adminClient.PatchGroup(ctx, groupB.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reassign the override's attribution to groupB.
|
||||
updated, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: groupB.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, groupB.ID, updated.GroupID, "upsert should change attributed group")
|
||||
|
||||
// GET reflects the new group.
|
||||
got, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, groupB.ID, got.GroupID, "GET should reflect new group")
|
||||
})
|
||||
|
||||
t.Run("Upsert/EveryoneGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// The Everyone group has id == organization_id, and the target user
|
||||
// is implicitly a member via organization_members rather than
|
||||
// group_members. The membership trigger queries
|
||||
// group_members_expanded (a UNION of both tables), so this case
|
||||
// exercises the organization_members branch.
|
||||
everyoneGroupID := targetUser.OrganizationIDs[0]
|
||||
|
||||
override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: everyoneGroupID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "should be able to attribute override to Everyone group")
|
||||
require.Equal(t, targetUser.ID, override.UserID)
|
||||
require.Equal(t, everyoneGroupID, override.GroupID)
|
||||
require.EqualValues(t, 500_000_000, override.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/AcceptsZeroSpendLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// 0 is a valid value: it blocks all spend for the user.
|
||||
override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, override.SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsNegativeSpend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: -1,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// A group_id that doesn't exist (or that the caller can't see)
|
||||
// is rejected by the visibility check before the membership check.
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: uuid.New(),
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Upsert/RejectsNonMemberGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a second group the target is NOT a member of.
|
||||
outsiderGroup, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
|
||||
Name: "outsider-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: outsiderGroup.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Get/AbsentReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Get/UnknownUserReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, _, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UserAIBudgetOverride(ctx, uuid.New())
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Delete/RoundTrip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID))
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Delete/AbsentReturns404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
err := adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserAIBudgetOverrideRoleAccess verifies the authz matrix for the roles
|
||||
// expected to interact with user budget overrides:
|
||||
//
|
||||
// - Owner / UserAdmin: full CRUD.
|
||||
// - OrgAdmin / OrgUserAdmin: read-only. Writes require ActionUpdate on the
|
||||
// User resource (site-scoped), which neither role has.
|
||||
//
|
||||
//nolint:tparallel // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race.
|
||||
func TestUserAIBudgetOverrideRoleAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID))
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
group, err := userAdminClient.CreateGroup(setupCtx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "role-access-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
Client *codersdk.Client
|
||||
CanWrite bool
|
||||
}{
|
||||
{Name: "Owner", Client: ownerClient, CanWrite: true},
|
||||
{Name: "UserAdmin", Client: userAdminClient, CanWrite: true},
|
||||
{Name: "OrgAdmin", Client: orgAdminClient, CanWrite: false},
|
||||
{Name: "OrgUserAdmin", Client: orgUserAdminClient, CanWrite: false},
|
||||
}
|
||||
|
||||
//nolint:paralleltest // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race.
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Each case gets a fresh target user.
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
_, err := userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
upsertReq := codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
}
|
||||
|
||||
if tc.CanWrite {
|
||||
// Full CRUD lifecycle.
|
||||
override, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
require.NoError(t, err, "PUT")
|
||||
require.Equal(t, group.ID, override.GroupID)
|
||||
|
||||
got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "GET")
|
||||
require.EqualValues(t, 500_000_000, got.SpendLimitMicros)
|
||||
|
||||
err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "DELETE")
|
||||
} else {
|
||||
// PUT rejected.
|
||||
_, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "PUT")
|
||||
|
||||
// Seed a row via UserAdmin so we can verify read access still works.
|
||||
_, err = userAdminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// GET still works (all roles have ActionRead on User).
|
||||
got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "GET")
|
||||
require.EqualValues(t, 500_000_000, got.SpendLimitMicros)
|
||||
|
||||
// DELETE rejected.
|
||||
err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "DELETE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserAIBudgetOverrideDeletedOnMembershipRemoval verifies that a per-user
|
||||
// override is deleted automatically when the user loses membership in the
|
||||
// attributed group. Two paths are exercised:
|
||||
//
|
||||
// - RegularGroup: membership stored in group_members; removed via
|
||||
// PatchGroup with RemoveUsers.
|
||||
// - EveryoneGroup: membership stored in organization_members; removed
|
||||
// via DeleteOrganizationMember.
|
||||
func TestUserAIBudgetOverrideDeletedOnMembershipRemoval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
// "Regular group" means any group except "Everyone".
|
||||
t.Run("RegularGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "cascade-regular-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: group.ID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "set override")
|
||||
|
||||
// Sanity-check the override exists.
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "override should exist before removal")
|
||||
|
||||
_, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
RemoveUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err, "remove user from group")
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(),
|
||||
"override should be deleted after user is removed from the attributed group")
|
||||
})
|
||||
|
||||
t.Run("EveryoneGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
// The Everyone group has id == organization_id.
|
||||
everyoneGroupID := owner.OrganizationID
|
||||
|
||||
_, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{
|
||||
GroupID: everyoneGroupID,
|
||||
SpendLimitMicros: 500_000_000,
|
||||
})
|
||||
require.NoError(t, err, "set override")
|
||||
|
||||
// Sanity-check the override exists.
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
require.NoError(t, err, "override should exist before removal")
|
||||
|
||||
err = adminClient.DeleteOrganizationMember(ctx, owner.OrganizationID, targetUser.ID.String())
|
||||
require.NoError(t, err, "remove user from organization")
|
||||
|
||||
_, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(),
|
||||
"override should be deleted after user is removed from the organization")
|
||||
})
|
||||
}
|
||||
|
||||
// setupUserAIBudgetOverrideTest returns an Admin client, a target user, and a
|
||||
// group the target user is a member of.
|
||||
func setupUserAIBudgetOverrideTest(t *testing.T) (adminClient *codersdk.Client, targetUser codersdk.User, group codersdk.Group) {
|
||||
t.Helper()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{DeploymentValues: dv},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureTemplateRBAC: 1,
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
adminClient, _ = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
_, targetUser = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
g, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "override-test-group",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
g, err = adminClient.PatchGroup(ctx, g.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{targetUser.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return adminClient, targetUser, g
|
||||
}
|
||||
|
||||
// setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it.
|
||||
func setupGroupAIBudgetTest(t *testing.T) (adminClient *codersdk.Client, group codersdk.Group) {
|
||||
t.Helper()
|
||||
|
||||
@@ -596,6 +596,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Get("/", api.userQuietHoursSchedule)
|
||||
r.Put("/", api.putUserQuietHoursSchedule)
|
||||
})
|
||||
r.Route("/users/{user}/ai/budget", func(r chi.Router) {
|
||||
// AI cost controls are a paid feature (AI Governance add-on).
|
||||
r.Use(
|
||||
api.RequireFeatureMW(codersdk.FeatureAIBridge),
|
||||
apiKeyMiddleware,
|
||||
httpmw.ExtractUserParam(options.Database),
|
||||
)
|
||||
r.Get("/", api.userAIBudgetOverride)
|
||||
r.Put("/", api.upsertUserAIBudgetOverride)
|
||||
r.Delete("/", api.deleteUserAIBudgetOverride)
|
||||
})
|
||||
r.Route("/prebuilds", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
|
||||
Reference in New Issue
Block a user