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
+65
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
type AIBridgeInterception struct {
@@ -350,3 +351,67 @@ func (c *Client) AIBridgeListClients(ctx context.Context) ([]string, error) {
var clients []string
return clients, json.NewDecoder(res.Body).Decode(&clients)
}
type GroupAIBudget struct {
GroupID uuid.UUID `json:"group_id" format:"uuid"`
SpendLimitMicros int64 `json:"spend_limit_micros"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
type UpsertGroupAIBudgetRequest struct {
SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
}
// GroupAIBudget returns the AI spend budget configured for the given group.
func (c *Client) GroupAIBudget(ctx context.Context, group uuid.UUID) (GroupAIBudget, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
nil,
)
if err != nil {
return GroupAIBudget{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupAIBudget{}, ReadBodyAsError(res)
}
var resp GroupAIBudget
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpsertGroupAIBudget creates or updates the AI spend budget for the given group.
func (c *Client) UpsertGroupAIBudget(ctx context.Context, group uuid.UUID, req UpsertGroupAIBudgetRequest) (GroupAIBudget, error) {
res, err := c.Request(ctx, http.MethodPut,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
req,
)
if err != nil {
return GroupAIBudget{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupAIBudget{}, ReadBodyAsError(res)
}
var resp GroupAIBudget
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// DeleteGroupAIBudget removes the AI spend budget for the given group.
func (c *Client) DeleteGroupAIBudget(ctx context.Context, group uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
nil,
)
if err != nil {
return xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}