feat: add per-group AI budget table and endpoints (#25203)

Closes
https://linear.app/codercom/issue/AIGOV-284/add-group-budgets-table-and-crud-api

## Summary

Adds the `group_ai_budgets` table and the following endpoints:

- `GET /api/v2/groups/{group}/ai/budget`
- `PUT /api/v2/groups/{group}/ai/budget`
- `DELETE /api/v2/groups/{group}/ai/budget`

Each group may have at most one budget row. If no row exists, no budget
is enforced.

### Feature gate
  
Added `RequireFeatureMW(FeatureAIBridge)` on the `/ai/budget` sub-route.

## RBAC

Authorization reuses `rbac.ResourceGroup` with the existing
`.InOrganization(...).WithID(...)` scoping model.

The `dbauthz` wrappers load the parent `groups` row and authorize
against it.

No new resource type is introduced. As a result, anyone with
`group:update` permissions (Owner, OrgAdmin, or UserAdmin within the
organization) can manage AI budgets for that group.

## Read access for group members

`database.Group.RBACObject()` grants `policy.ActionRead` to all members
of the group through the group ACL:

```go
func (g Group) RBACObject() rbac.Object {
	return rbac.ResourceGroup.WithID(g.ID).
		InOrg(g.OrganizationID).
		// Group members can read the group.
		WithGroupACL(map[string][]policy.Action{
			g.ID.String(): {
				policy.ActionRead,
			},
		})
}
```

Because the `GET` endpoint authorizes against the same loaded `Group`
object, any group member can call:

```text
GET /api/v2/groups/{group}/ai/budget
```

`PUT` and `DELETE` remain admin-only. The group ACL grants only
`ActionRead`, so write operations continue to require role-based
`group:update` permissions.

## Alternative considered

A dedicated `rbac.ResourceGroupAiBudget` resource would allow budget
management to be separated from general group administration.

We decided not to add that complexity for now.
This commit is contained in:
Yevhenii Shcherbina
2026-05-14 15:54:37 -04:00
committed by GitHub
parent d97f5ae2a6
commit 238968cfa0
25 changed files with 1040 additions and 0 deletions
+60
View File
@@ -2280,6 +2280,22 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
return i, err
}
const deleteGroupAIBudget = `-- name: DeleteGroupAIBudget :one
DELETE FROM group_ai_budgets WHERE group_id = $1 RETURNING group_id, spend_limit_micros, created_at, updated_at
`
func (q *sqlQuerier) DeleteGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAiBudget, error) {
row := q.db.QueryRowContext(ctx, deleteGroupAIBudget, groupID)
var i GroupAiBudget
err := row.Scan(
&i.GroupID,
&i.SpendLimitMicros,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one
SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at
FROM ai_model_prices
@@ -2307,6 +2323,24 @@ func (q *sqlQuerier) GetAIModelPriceByProviderModel(ctx context.Context, arg Get
return i, err
}
const getGroupAIBudget = `-- name: GetGroupAIBudget :one
SELECT group_id, spend_limit_micros, created_at, updated_at
FROM group_ai_budgets
WHERE group_id = $1
`
func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (GroupAiBudget, error) {
row := q.db.QueryRowContext(ctx, getGroupAIBudget, groupID)
var i GroupAiBudget
err := row.Scan(
&i.GroupID,
&i.SpendLimitMicros,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec
INSERT INTO ai_model_prices (
provider, model, input_price, output_price, cache_read_price, cache_write_price
@@ -2335,6 +2369,32 @@ func (q *sqlQuerier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessa
return err
}
const upsertGroupAIBudget = `-- name: UpsertGroupAIBudget :one
INSERT INTO group_ai_budgets (group_id, spend_limit_micros)
VALUES ($1, $2)
ON CONFLICT (group_id) DO UPDATE SET
spend_limit_micros = EXCLUDED.spend_limit_micros,
updated_at = NOW()
RETURNING group_id, spend_limit_micros, created_at, updated_at
`
type UpsertGroupAIBudgetParams struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIBudgetParams) (GroupAiBudget, error) {
row := q.db.QueryRowContext(ctx, upsertGroupAIBudget, arg.GroupID, arg.SpendLimitMicros)
var i GroupAiBudget
err := row.Scan(
&i.GroupID,
&i.SpendLimitMicros,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one
SELECT
COUNT(*)