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:
Yevhenii Shcherbina
2026-05-29 10:08:25 -04:00
committed by GitHub
parent 9448624d2d
commit 1a91d31793
25 changed files with 1510 additions and 0 deletions
+145
View File
@@ -9171,6 +9171,110 @@ const docTemplate = `{
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": [
"Enterprise"
],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": [
@@ -24663,6 +24767,23 @@ const docTemplate = `{
}
}
},
"codersdk.UpsertUserAIBudgetOverrideRequest": {
"type": "object",
"required": [
"group_id"
],
"properties": {
"group_id": {
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer",
"minimum": 0
}
}
},
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
"type": "object",
"properties": {
@@ -24817,6 +24938,30 @@ const docTemplate = `{
}
}
},
"codersdk.UserAIBudgetOverride": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {
+131
View File
@@ -8132,6 +8132,98 @@
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": ["Enterprise"],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": ["application/json"],
@@ -22696,6 +22788,21 @@
}
}
},
"codersdk.UpsertUserAIBudgetOverrideRequest": {
"type": "object",
"required": ["group_id"],
"properties": {
"group_id": {
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer",
"minimum": 0
}
}
},
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
"type": "object",
"properties": {
@@ -22829,6 +22936,30 @@
}
}
},
"codersdk.UserAIBudgetOverride": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {