mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
@@ -0,0 +1,7 @@
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_org_member_delete ON organization_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_org_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_group_member_delete ON group_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_group_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_enforce_user_ai_budget_override_membership ON user_ai_budget_overrides;
|
||||
DROP FUNCTION IF EXISTS enforce_user_ai_budget_override_membership;
|
||||
DROP TABLE IF EXISTS user_ai_budget_overrides CASCADE;
|
||||
@@ -0,0 +1,76 @@
|
||||
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 applied to the user, in micro-units (1 unit = 1,000,000).
|
||||
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()
|
||||
-- The membership invariant (user must be a member of the attributed
|
||||
-- group, including when that group is "Everyone") would naturally be
|
||||
-- a composite FK to group_members_expanded, but PostgreSQL does not
|
||||
-- allow FKs to views. It's enforced instead by a write-time trigger
|
||||
-- on this table and removal-time triggers on the underlying
|
||||
-- membership tables.
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.';
|
||||
|
||||
-- Write-time membership check. Reads from group_members_expanded so
|
||||
-- the "Everyone" group (whose membership lives in organization_members)
|
||||
-- is correctly handled. Raises check_violation with a constraint name
|
||||
-- so callers can match it via database.IsCheckViolation in Go.
|
||||
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM group_members_expanded
|
||||
WHERE user_id = NEW.user_id AND group_id = NEW.group_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership
|
||||
BEFORE INSERT OR UPDATE ON user_ai_budget_overrides
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_ai_budget_override_membership();
|
||||
|
||||
-- When a user is removed from a regular group (any group except
|
||||
-- "Everyone"), delete any override attributed to that group.
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete
|
||||
BEFORE DELETE ON group_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_group_member_delete();
|
||||
|
||||
-- When a user is removed from an organization, delete any override
|
||||
-- attributed to that organization's "Everyone" group (which has
|
||||
-- id == organization_id).
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.organization_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete
|
||||
BEFORE DELETE ON organization_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_org_member_delete();
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- Seed a group_members row so the override below references a real
|
||||
-- membership.
|
||||
INSERT INTO group_members (
|
||||
user_id,
|
||||
group_id
|
||||
) VALUES
|
||||
('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO user_ai_budget_overrides (
|
||||
user_id,
|
||||
group_id,
|
||||
spend_limit_micros
|
||||
) VALUES
|
||||
('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 500000000);
|
||||
Reference in New Issue
Block a user