fix: stop group spend limits from leaking across org boundaries (#24294)

Three SQL queries (`GetUserGroupSpendLimit`,
`ResolveUserChatSpendLimit`, `GetUserChatSpendInPeriod`) aggregated chat
spend limits and usage globally across all organizations. A restrictive
group limit in org A would bleed into org B.

## Changes

- Add `organization_id` parameter to all three SQL queries in
`coderd/database/queries/chats.sql`
- When nil UUID is passed, queries fall back to global behavior
(backward compat for HTTP dashboard endpoints)
- When real org ID is passed, limits and spend are scoped to that
organization
- Thread `organizationID` through `ResolveUsageLimitStatus` →
`checkUsageLimit` → all chatd call sites
- Update dbauthz wrappers for new param structs
- HTTP endpoints (`chatCostSummary`, `getMyChatUsageLimitStatus`) pass
`uuid.Nil` with TODO for future org-scoped UI
- Add `TestResolveUsageLimitStatus_OrgScoped` with 5 test cases covering
org isolation, nil-UUID fallback, spend scoping, and user override
priority

Closes coder/internal#1466

> 🤖
This commit is contained in:
Cian Johnston
2026-04-14 16:56:17 +01:00
committed by GitHub
parent 4d4266a4ad
commit c552f9f281
12 changed files with 542 additions and 79 deletions
+65 -22
View File
@@ -6747,19 +6747,31 @@ SELECT COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_spend_micros
FROM chat_messages cm
JOIN chats c ON c.id = cm.chat_id
WHERE c.owner_id = $1::uuid
AND cm.created_at >= $2::timestamptz
AND cm.created_at < $3::timestamptz
AND ($2::uuid IS NULL
OR c.organization_id = $2::uuid)
AND cm.created_at >= $3::timestamptz
AND cm.created_at < $4::timestamptz
AND cm.total_cost_micros IS NOT NULL
`
type GetUserChatSpendInPeriodParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
}
// Returns the total spend for a user in the given period.
// When organization_id is NULL, spend across all organizations is
// returned (global behavior). Otherwise only spend within the
// specified organization is included.
func (q *sqlQuerier) GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getUserChatSpendInPeriod, arg.UserID, arg.StartTime, arg.EndTime)
row := q.db.QueryRowContext(ctx, getUserChatSpendInPeriod,
arg.UserID,
arg.OrganizationID,
arg.StartTime,
arg.EndTime,
)
var total_spend_micros int64
err := row.Scan(&total_spend_micros)
return total_spend_micros, err
@@ -6770,13 +6782,23 @@ SELECT COALESCE(MIN(g.chat_spend_limit_micros), -1)::bigint AS limit_micros
FROM groups g
JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = $1::uuid
AND ($2::uuid IS NULL
OR g.organization_id = $2::uuid)
AND g.chat_spend_limit_micros IS NOT NULL
`
type GetUserGroupSpendLimitParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
}
// Returns the minimum (most restrictive) group limit for a user.
// Returns -1 if the user has no group limits applied.
func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getUserGroupSpendLimit, userID)
// Returns -1 if no group limits match the specified scope.
// When organization_id is NULL, groups across all organizations are
// considered (global behavior). Otherwise only groups within the
// specified organization are considered.
func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, arg GetUserGroupSpendLimitParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getUserGroupSpendLimit, arg.UserID, arg.OrganizationID)
var limit_micros int64
err := row.Scan(&limit_micros)
return limit_micros, err
@@ -7300,15 +7322,17 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID)
const resolveUserChatSpendLimit = `-- name: ResolveUserChatSpendLimit :one
SELECT CASE
-- If limits are disabled, return -1.
WHEN NOT cfg.enabled THEN -1
-- Individual override takes priority.
WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
-- Group limit (minimum across all user's groups) is next.
WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
-- Fall back to global default.
ELSE cfg.default_limit_micros
END::bigint AS effective_limit_micros
END::bigint AS effective_limit_micros,
CASE
WHEN NOT cfg.enabled THEN 'disabled'
WHEN u.chat_spend_limit_micros IS NOT NULL THEN 'user'
WHEN gl.limit_micros IS NOT NULL THEN 'group'
ELSE 'default'
END AS limit_source
FROM chat_usage_limit_config cfg
CROSS JOIN users u
LEFT JOIN LATERAL (
@@ -7316,22 +7340,41 @@ LEFT JOIN LATERAL (
FROM groups g
JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = $1::uuid
AND ($2::uuid IS NULL
OR g.organization_id = $2::uuid)
AND g.chat_spend_limit_micros IS NOT NULL
) gl ON TRUE
WHERE u.id = $1::uuid
LIMIT 1
`
type ResolveUserChatSpendLimitParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
}
type ResolveUserChatSpendLimitRow struct {
EffectiveLimitMicros int64 `db:"effective_limit_micros" json:"effective_limit_micros"`
LimitSource string `db:"limit_source" json:"limit_source"`
}
// Resolves the effective spend limit for a user using the hierarchy:
// 1. Individual user override (highest priority)
// 2. Minimum group limit across all user's groups
// 3. Global default from config
// 1. Individual user override (highest priority, applies globally across
// all organizations since it lives on the users table)
// 2. Minimum group limit across the user's groups
// 3. Global default from config
//
// Returns -1 if limits are not enabled.
func (q *sqlQuerier) ResolveUserChatSpendLimit(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, resolveUserChatSpendLimit, userID)
var effective_limit_micros int64
err := row.Scan(&effective_limit_micros)
return effective_limit_micros, err
// When organization_id is NULL, groups across all organizations are
// considered (global behavior). Otherwise only groups within the
// specified organization are considered.
// limit_source indicates which tier won: 'user', 'group', 'default',
// or 'disabled'.
func (q *sqlQuerier) ResolveUserChatSpendLimit(ctx context.Context, arg ResolveUserChatSpendLimitParams) (ResolveUserChatSpendLimitRow, error) {
row := q.db.QueryRowContext(ctx, resolveUserChatSpendLimit, arg.UserID, arg.OrganizationID)
var i ResolveUserChatSpendLimitRow
err := row.Scan(&i.EffectiveLimitMicros, &i.LimitSource)
return i, err
}
const softDeleteChatMessageByID = `-- name: SoftDeleteChatMessageByID :exec