feat: add GET /organizations/{org}/groups/ai/spend - #27123
Conversation
Docs preview📖 View docs preview for |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
bd0fc88 to
ca91968
Compare
db14366 to
a06d471
Compare
ca91968 to
802751b
Compare
a06d471 to
a35ad30
Compare
802751b to
2b21c5f
Compare
2b21c5f to
b6c2225
Compare
a35ad30 to
6580cdc
Compare
c27116f to
98e74d3
Compare
| return g.Group.RBACObject() | ||
| } | ||
|
|
||
| func (g GetOrganizationGroupsAISpendRow) RBACObject() rbac.Object { |
There was a problem hiding this comment.
AFAIU, for full parity with /api/v2/organizations/{org}/groups, we need this for regular org members. These members see all groups in GET /organizations/{org}/groups, but without this they would get empty rows from this new endpoint.
Note that regular members already see the group's configured spend limit via the existing group budget endpoint (/api/v2/groups/{group}/ai/budget), so the only new property here is the aggregate current spend.
As a result, this comes down to: should regular members have visibility into their groups' current AI spend? Let me know what you think.
There was a problem hiding this comment.
P2 [CRF-5] Answering your question: as written, group AI spend inherits group-read authz, so on a default deployment every org member can read every group's aggregate spend, and a human needs to decide whether that is intended (Pariston P2, Kurapika/Knov P3)
GetOrganizationGroupsAISpendRow.RBACObject delegates to Group.RBACObject, so visibility equals GET /organizations/{org}/groups. A plain org member gets org-wide ResourceGroup read only when org.ShareableWorkspaceOwners == everyone (roles.go:1168), which is the column default; the passing Member case (a user not in the group) confirms it. Meruem's nuance: the budget limit is not new exposure (GetGroupAIBudget already authorizes member read), so the only incremental disclosure here is each group's aggregate current_spend_micros, summed across members the caller may not be able to enumerate individually. This is deliberate parity and not a code defect, but cost-control spend is a different sensitivity class from group identity. Per our review policy an agent can't accept this gap as permanent: either confirm members should see group spend (and keep group-read authz), or gate spend behind an admin-level action/resource. Flagging so it's decided rather than defaulted.
🤖
| // URL length limits and match expected UI batch sizes. | ||
| const maxOrganizationGroupsAISpendGroupIDs = 100 | ||
|
|
||
| // @Summary Get organization groups AI spend |
There was a problem hiding this comment.
Other alternatives could be POST or ideally QUERY. Since QUERY is still a draft HTTP method and not widely used, and since similar endpoints like /api/v2/groups?group_ids=... use GET with the same comma-separated batch pattern, going with GET for consistency.
There was a problem hiding this comment.
On GET vs POST/QUERY: the panel agrees GET is the right call. 100 UUIDs is roughly 3,700 characters of query string, well within URL limits; GET keeps the endpoint idempotent and cacheable and matches /api/v2/groups?group_ids=...; the cap is enforced on the parsed UUID count and ANY(@group_ids) dedups in SQL, so duplicates cannot fan out. No change needed.
🤖
eb5f19c to
0830e8f
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 3 | Last posted: Round 3, 13 findings (1 P1, 3 P2, 4 P3, 1 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventory - PR #27123Findings
Contested and acknowledgedNone this round. Round logRound 3 updateBLOCKED (churn guard). New commit f402a88 ("chore: address review comments"). 9 addressed (CRF-2/3/4/6/8/9/10/11/13, all author-claimed, unverified since panel deferred), 1 acknowledged (CRF-5, human johnstcn accepted as-is), 2 silent (CRF-7 P3 and CRF-12 Note: no code change, no author response). No panel spawned. Review blocked pending an author response (fix, ticket, or reasoned won't-fix) on CRF-7 and CRF-12. Reviewed against 304f127..f402a88. Round 2Panel (first panel round), 20 reviewers + Netero. Churn guard PROCEED (CRF-1, CRF-2 claimed fixed). Panel disproved the CRF-2 closure: orchestrator ran TestOrganizationGroupsAISpendRoleAccess on head 4b4aa4d, OtherOrgMember FAILs with 404 while the other five pass (CRF-3, reopens CRF-2). Orchestrator also verified CRF-4: route nests under templateRBACEnabledMW (= RequireFeatureMW(FeatureTemplateRBAC)); both gate tests omit FeatureTemplateRBAC so they 403 at the parent before the route's own gates, asserting only StatusForbidden. New: 1 P1, 2 P2, 5 P3, 4 Note. 2 dropped (scope). Netero round 2: no new findings, CRF-1/CRF-2 code present. Reviewed against 304f127..4b4aa4d. Round 1Netero-only first-pass (P2 present, pre-panel gate). 1 P2, 1 Note. Reviewed against 61e5253..0830e8f. Orchestrator verified both findings against the head worktree: RBACObject reconstructs the ACL that Group.RBACObject builds (modelmethods.go:446-455 vs 461-469); role-access test cases are all WantGroupInResponse:true so the else/require.Empty branch never runs. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
First-pass review (Netero only). This is a mechanical first pass, so the full review panel has not yet looked at this PR. The panel will review once these findings are addressed. Posting these early so they don't cost parallel review time later.
The change is well-tested: 76% test density, and the SQL query has genuine coverage for period normalization, cross-org exclusion, and cross-user aggregation (NormalizesNonUTCPeriodStart and ExcludesSpendBeforePeriodStart would fail without the query's date handling). The shared AISpendPeriodWindow reuse across UserAISpendStatus and the new response type is clean, and the route/handler/SDK/generated code all line up.
Severity count: 1 P2, 1 Note.
The P2 is the one to weigh before the panel round. As Netero put it: "This is authorization logic, so the duplication is a drift hazard." The new row's RBACObject rebuilds the same ACL that Group.RBACObject already produces, so a future change to group read authorization would land on GET /organizations/{org}/groups but silently miss this endpoint. Sibling rows (GetGroupsRow) avoid this by delegating.
The Note is a design question your own inline comment already raises: the role-access test asserts every role (including a plain Member) sees the group, so it never exercises the filtered-out path. Worth deciding whether a role should be excluded and testing that.
🤖 This review was automatically generated with Coder Agents.
0830e8f to
4b4aa4d
Compare
|
/coder-agents-review |
4b4aa4d to
8ef0651
Compare
There was a problem hiding this comment.
Panel round (first full-panel pass). Strong PR overall: the SQL aggregate is correct by construction (the LEFT JOIN on group_ai_budgets can't fan out the spend SUM because the budget PK is group_id), cross-org isolation is enforced at both the SQL WHERE clause and the dbauthz post-filter, and the raw-SQL test layer genuinely hunts edges (cross-org exclusion, id-not-in-list, period boundary, cross-user aggregation, non-UTC normalization). The AISpendPeriodWindow extraction is a clean, minimal refactor with no wire-format change, and CRF-1 was fixed correctly by delegating to Group.RBACObject.
But the CRF-2 fix regressed. The OtherOrgMember case added to revive the dead empty-response branch fails deterministically: I ran TestOrganizationGroupsAISpendRoleAccess on head 4b4aa4d and OtherOrgMember returns 404 (blocked at the org-membership middleware, before the handler), while the other five cases pass. So CI is red and the branch CRF-2 targeted is still dead. That is a merge blocker.
Two P2s follow. The license/experiment gate tests don't test their gates: the route nests under templateRBACEnabledMW, so both negative tests 403 at the parent FeatureTemplateRBAC check before reaching the FeatureAIBridge/experiment gates they name (Ryosuke proved it by deleting each gate, the tests stayed green). And your own open question about member visibility is now a concrete disclosure decision a human must make: on a default deployment any org member can read every group's aggregate AI spend.
Severity count: 1 P1, 2 P2, 5 P3, 1 Nit, 4 Note.
Hisoka, who found nothing to break, put the shape of it well: "I came looking for a fight. I fought the join, the clock, the routing, the embedding, and the authz filter. None of them broke." The engine is sound. The instrument panel (the two gate tests) reads green while measuring nothing, and one committed test is red.
enterprise/coderd/coderd.go:607
P3 [CRF-7] The sibling /groups/{group}/ai/budget route lacks the ExperimentAIGatewayCostControl gate that every other cost-control route carries (Razor P3, Meruem P3)
The new spend route and the user AI routes gate on both the experiment and FeatureAIBridge (with the TODO(AIGOV-443) kill-switch comment). The group budget CRUD routes at coderd.go:607-613 gate on FeatureAIBridge only. Consequence: on a licensed deployment with the experiment off, operators can still create/read/delete group budgets while reading spend returns 403, an incomplete kill-switch. This route is outside this PR's diff, but you're actively extending this surface, so you're the right person to reconcile it. The class fix is to hang the experiment gate on the cost-control boundary once rather than per leaf route. A human should decide whether the divergence is intended.
🤖
🤖 This review was automatically generated with Coder Agents.
| // @Param group_ids query string true "Comma-separated list of group IDs (maximum 100)" | ||
| // @Success 200 {object} codersdk.OrganizationGroupsAISpend | ||
| // @Router /api/v2/organizations/{organization}/groups/ai/spend [get] | ||
| func (api *API) organizationGroupsAISpend(rw http.ResponseWriter, r *http.Request) { |
There was a problem hiding this comment.
The endpoint GET /api/v2/organizations/{org}/groups is not paginated and currently has no limit on the number of groups returned. However, for this new endpoint we are establishing a limit of 100 group_ids as input, meaning that if the former returns more than 100 groups, it is the caller's responsibility (e.g. the UI) to batch across multiple requests. AFAIK the UI has a default pagination of 25 (DEFAULT_RECORDS_PER_PAGE), so maybe this number is more appropriate for this new endpoint? A possible follow-up would be to add pagination to the org groups endpoint. Let me know what you think.
There was a problem hiding this comment.
I think it's fine as-is, although we should clearly document the limitation for callers in the API doc.
| LEFT JOIN group_ai_budgets budget ON budget.group_id = groups.id | ||
| LEFT JOIN ai_user_daily_spend spend | ||
| ON spend.effective_group_id = groups.id | ||
| AND spend.day >= ((@period_start::timestamptz) AT TIME ZONE 'UTC')::date |
There was a problem hiding this comment.
Should we introduce a @period_end argument as well here so we're aligned with codersdk.AISpendPeriodWindow?
There was a problem hiding this comment.
That is a good point 👍 It works today because this is currently only used for the current time period, so we effectively only need the spend up until NOW. But it definitely makes sense to apply the period end so it can also be used in the future for calls that require previous periods. I will address this in a follow-up PR, since we also apply this logic in GetUserAISpendSince.
| return g.Group.RBACObject() | ||
| } | ||
|
|
||
| func (g GetOrganizationGroupsAISpendRow) RBACObject() rbac.Object { |
| // @Param group_ids query string true "Comma-separated list of group IDs (maximum 100)" | ||
| // @Success 200 {object} codersdk.OrganizationGroupsAISpend | ||
| // @Router /api/v2/organizations/{organization}/groups/ai/spend [get] | ||
| func (api *API) organizationGroupsAISpend(rw http.ResponseWriter, r *http.Request) { |
There was a problem hiding this comment.
I think it's fine as-is, although we should clearly document the limitation for callers in the API doc.
|
/coder-agents-review |
There was a problem hiding this comment.
Re-review paused (churn guard blocked). Solid progress on f402a88: of the 12 open findings, 9 have code fixes and 1 (CRF-5, member visibility) was accepted by @johnstcn as fine as-is. The panel hasn't re-verified those fixes yet, because two findings are still silent (no code change and no response), and the review process holds the panel until every finding gets at least a reasoned answer.
Two items need a word from you before the panel re-runs:
-
CRF-7 (P3) - the sibling /groups/{group}/ai/budget route (coderd.go:607-613) gates on FeatureAIBridge only, without the ExperimentAIGatewayCostControl gate that this PR's spend route and the user AI routes carry. It's outside this PR's diff, so a fix here is optional, but it needs a disposition: fix it, file a follow-up ticket, or say why the divergence is intended.
-
CRF-12 (Note) - the aicostcontrol.sql comment says spend is aggregated "until NOW" while the query has no upper bound. It's correct today and mirrors GetUserAISpendSince, so a won't-fix is entirely reasonable; it just needs to be stated (a one-line "acknowledged, matches sibling, leaving as-is" clears it).
Neither requires a code change. A short reply on each thread (or a ticket link for CRF-7) unblocks the next round, at which point the panel will verify the nine fixes and re-check CRF-3/CRF-4 against the code.
🤖 This review was automatically generated with Coder Agents.
f402a88 to
312fa43
Compare
ca72acc to
50a654d
Compare
Merge activity
|

Description
Adds
GET /api/v2/organizations/{org}/groups/ai/spend?group_ids=...to return per-group AI spend and configured limits for a set of groups in an organization.In the UI, this endpoint is used alongside the existing
/api/v2/organizations/{org}/groupsendpoint. AI spend data is kept separate from that endpoint so that:UI flow:
/api/v2/organizations/{org}/groups→ returns the organization's groups./api/v2/organizations/{org}/groups/ai/spend?group_ids=...with the IDs from step 1.The groups endpoint from 1) is currently not paginated, but if pagination is added later, this design keeps the two responses in sync. This spend endpoint intentionally takes
group_idsrather than paginating on its own, since it depends on the group set from step 1. Pagination could be added in the future, especially for Cost Control-focused pages.Changes
codersdk.OrganizationGroupsAISpendandOrganizationGroupAISpendtypes, plus a sharedAISpendPeriodWindowembedded in the spend response.GetOrganizationGroupsAISpendSQL query with a dbauthz per-row filter that mirrorsGET /organizations/{org}/groups./organizations/{organization}/groups/ai/spendwith a requiredgroup_idsquery param (cap 100). Callers with more than 100 groups are expected to batch across multiple requests.Closes https://linear.app/codercom/issue/AIGOV-466/backend-organization-groups-endpoint-with-groups-spend
Note
Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira