feat: enforce AI Bridge interception permission at the AI Gateway - #27530
Draft
jscottmiller wants to merge 39 commits into
Draft
feat: enforce AI Bridge interception permission at the AI Gateway#27530jscottmiller wants to merge 39 commits into
jscottmiller wants to merge 39 commits into
Conversation
Adds permission-based license seat counting. When enabled, the seat count for the user_limit feature is computed by asking the RBAC engine which active users can create a workspace, instead of counting every active user. Users without workspace-create capability (gateway accounts, e.g. AI-Gateway-only users) no longer consume seats. - Add GetActiveUsersAuthorizationRoles bulk query returning effective roles (implied member roles, org defaults) and groups for all seat candidates. - Add license.CountWorkspaceCapableUsers, which evaluates workspace.create per organization plus the any-organization form for site-wide roles, deduplicating evaluation by role/group signature. - Gate the count on both the permission-based-licensing experiment (intentionally not in ExperimentsSafe) and a valid license carrying the AI Governance addon. The addon is only known after license claims are parsed, so Entitlements() passes a lazy WorkspaceCapableUserCountFn (following the ManagedAgentCountFn precedent) and LicensesEntitlements resolves it when the addon is present, overwriting the ActiveUserCount observed by the user_limit feature and its warnings. The legacy GetActiveUserCount path is unchanged otherwise. Behavior notes: - Licenses in their grace period still gate the count; it reverts to the legacy count only when the license hard-expires. - Count errors fall back to the legacy count and record an entitlement error; context cancellation aborts the computation. - Without the minimum-implicit-member experiment the new count matches the legacy count, except that zero-org users only count when a site role grants workspace-create.
A failed capable count previously fell back to the legacy active user count and recorded an entitlements error, so a persistent failure silently produced a higher seat count. The legacy GetActiveUserCount path aborts the entitlements computation on error, and the caller keeps the previous entitlements set. Count errors now do the same: the refresh fails and the count stays stale rather than becoming silently different.
Group memberships only influence authorization through object ACL matching, and the workspace-create evaluation uses objects without ACLs, so groups cannot change the outcome. Including them in the dedupe signature made evaluation cost scale with unique group combinations (near-unique per user under IdP group sync) instead of unique role sets (typically single digits). Remove groups from the bulk query, the evaluation subject, and the signature together so the cached verdicts stay coherent with what is evaluated. TestWorkspaceCreateIgnoresGroups pins the group-independence assumption: it authorizes group-laden and group-free subjects across representative role sets against the same ACL-less object shapes the counter uses, including the Everyone-group ID as an adversarial membership. If the policy ever becomes group-sensitive for these objects, the test fails and groups must be reintroduced in all three places.
…at counting A role string that fails to parse made CountWorkspaceCapableUsers return an error, aborting the entitlements computation on every refresh until the row was fixed. Authorization fails closed on such roles, so the user cannot create workspaces: treat them as not workspace-capable and keep counting instead of failing the whole count.
…ls to parse during seat counting Threads a logger through Entitlements into CountWorkspaceCapableUsers so an unparseable stored role, which is tolerated by counting the user as not workspace-capable, is surfaced to operators instead of being silently skipped. Logged once per unique role set per refresh.
…limit warnings With permission-based counting active, the over-limit and expired-limit warnings printed the workspace-capable count while calling it "active users", misstating both numbers to admins in the dashboard banner and CLI. Say "workspace-capable users" when that is what was counted, and cover the over-limit, under-limit, and grace-period warning texts with tests.
…eat-count evaluations Without a cache on the context, rolestore.Expand fetched custom roles from the database once per unique role set. Establish the cache once in CountWorkspaceCapableUsers so each distinct custom role is fetched at most once per count.
…per user row GetActiveUsersAuthorizationRoles computed each user's org roles with a correlated scalar subquery, which Postgres executes as a SubPlan once per user row. Aggregate memberships in a CTE grouped by user_id and hash-join it to the filtered users instead, producing a single-pass plan. Zero-membership users coalesce to an empty array, preserving the previous NULL-concat behavior.
…ounting Seat counting resolved custom roles through rolestore.Expand, one batched lookup per unique role set on cache miss. Add rolestore.PrefetchCustomRoles, which loads every custom role in a single unfiltered CustomRoles query and seeds the context role cache, and use it in CountWorkspaceCapableUsers so expansion runs without per-role-set database lookups.
… count doc comment
Emit an Info line from CountWorkspaceCapableUsers with the counted seats, the total eligible active users, the number of unique role sets evaluated, and the elapsed time. The line appears only when permission-based counting runs, so its presence also indicates which counting mode produced the user_limit value.
…seat counting Pins the cases where a user's workspace-create capability differs between organizations: a grant in any one org counts the user, and an org-scoped creation ban does not negate another org's grant.
…nd documentation - Document that permission-based-licensing is deliberately excluded from ExperimentsSafe, since --experiments='*' must not change seat counting as a side effect. - State the Actual pointer-copy invariant and the deliberate hard-fail choice in the seat-count comments. - Test that an AI Governance addon with unmet feature dependencies is skipped and does not activate permission-based counting. - Assert the return value in the GetActiveUsersAuthorizationRoles dbauthz test.
… addon license Two multi-license fixes for permission-based seat counting: - The user_limit merge keeps the highest limit across all licenses, so a license without the AI Governance addon could lend its higher limit to workspace-capable counting. Clamp the effective limit to the highest user_limit among addon-carrying licenses when the counting mode is active. - When the addon exists only on grace-period licenses, warn that counting reverts at full expiry, including the legacy active user count admins will then be measured by.
…and role-set scale Bulk-seeds users, org memberships, and custom roles at several shapes (uniform, many orgs, unique-per-user role sets, custom roles) and measures CountWorkspaceCapableUsers. Statistics are refreshed after seeding: bulk inserts otherwise leave the planner estimating near-empty tables, which degrades the roles query into a per-row nested-loop plan. Measured: ~4us per user on the row side, ~0.3ms per unique role set on the evaluation side.
…h a timeout The count runs on the API's long-lived context, so a slow database could stall the entitlements refresh indefinitely while holding the refresh slot. Cap it at 60 seconds; on expiry the error propagates and the previous entitlements keep being served.
Comments no longer reference gateway accounts or licensing pricing where the code itself is generic: the roles query documents its population and the no-ACL applicability of its results, and the counting comments state what is computed and why locally, not the product rationale.
…s licenses Each valid license's user_limit claim now forms a candidate pairing of seat limit and counting mode (workspace-capable when the license carries the AI Governance addon, all active users otherwise). The most favorable candidate is selected: one satisfied by its own count wins over any unsatisfied one, then higher entitlement, then higher limit. This replaces the addon-limit clamp, which forced the addon license's lower limit onto deployments whose non-addon license kept them compliant, while still never letting one license's limit combine with another license's counting mode.
… ordering Unit-tests betterUserLimit's ordering (compliance, entitlement, limit, addon tie-break, and its asymmetry) and adds integration cases: a grace-period addon pair that fits its count wins over an entitled non-addon pair that does not, carrying its grace entitlement and both warnings; equal limits prefer the addon pair.
The previous commit unintentionally replaced license_internal_test.go when adding TestBetterUserLimit; restore it and move the new test to its own file.
…ace-create checks The any-organization policy form resolves to the maximum per-org vote across the subject's memberships, so it allows exactly when some InOrg check would; the per-organization loop could never change the outcome. One authorization evaluation now runs per unique role set.
…ection into selectUserLimit Moves the candidate evaluation, capable-count resolution, and feature overwrite out of LicensesEntitlements into selectUserLimit, which returns a userLimitSelection consumed by the warning generation. featureArguments is passed by pointer so the capable-count write still lands in the caller's copy, which the user_limit Actual pointer aliases.
…nstead of its wire value
…icit The presence of WorkspaceCapableUserCountFn implicitly switched how FeatureUserLimit candidates were evaluated. Add a UserCountingMode enum to FeatureArguments as the authoritative switch: permission_based evaluates addon candidates with the counting function, while the active-users zero value never invokes it. Entitlements always provides the function and sets the mode from the experiment and authorizer; selecting permission-based counting without a function is a dev error.
…g selection cases - Database failures in the prefetch, the roles query, and a dangling custom-role lookup abort the count with wrapped errors. - PrefetchCustomRoles propagates fetch and conversion failures. - Permission-based mode without a counting function is a dev error. - Two addon candidates: the entitled higher-limit pair wins over the grace pair and suppresses the revert warning.
…ected by permission-based counting
- Construct the workspace-capable counting function only when the mode selects it, removing an unreachable authorizer guard; a nil function under the permission-based mode remains a dev error. - Resolve each candidate's count up front into resolvedCandidate, replacing the countFor closure and the capableCount/valid pair with a *int64, and shrinking betterUserLimit to two arguments. - Deduplicate role strings in authorizationSignature so equivalent role sets share a cache key.
…nsing Aligns the experiment, counting-mode constants, and selection fields with the CountWorkspaceCapableUsers vocabulary: the experiment becomes workspace-capable-licensing, the mode becomes UserCountingModeWorkspaceCapable, and UserCountingModeActive gains the descriptive value active_users instead of the empty string. The zero value still counts active users. Generated API docs and TS types updated.
…able count function Set WorkspaceCapableUserCountFn unconditionally in the FeatureArguments literal alongside ManagedAgentCountFn; the counting mode alone decides whether it is invoked. The nil-authorizer precondition moves into CountWorkspaceCapableUsers, where it is an ordinary testable guard instead of unreachable closure code.
… with a worked example
…subjects Replace the role-string signature with a sha256 of the evaluation subject's JSON form: the user ID is normalized to a fixed sentinel on both the subject and the object owner, roles and groups are sorted and deduplicated, and group memberships are now fetched by the roles query and included in both the evaluation and the key. Every subject field participates in the hash, so the count no longer assumes groups cannot influence workspace-create outcomes; TestWorkspaceCreateIgnoresGroups guarded that assumption and is removed with it. Benchmarks: row-side cost rises ~1.8x (344ms at 50k users), the evaluation-dominated worst case is unchanged.
…ew experiment Under the ai-gateway-seat-exclusion experiment, RecordInterception no longer records ai_seat_state usage for the initiator: AI Gateway access is licensed by the AI Governance add-on rather than per seat. Task workspace builds still claim AI Governance seats.
IsAuthorized now builds the initiator's RBAC subject via httpmw.UserRBACSubject and requires permission to create AI Bridge interceptions, rejecting the request otherwise. The permission is carried by the site member role's user-scoped grant; the org-member and service-account permission floors no longer duplicate it. Server-side faults during the check surface as ErrAuthorizationInternal rather than a policy denial. rolestore.Expand and UserRBACSubject accept narrow store interfaces so the aibridged server's store can satisfy them.
Contributor
Documentation CheckUpdates Needed
No docs action needed for the Automated review via Coder Agents |
jscottmiller
marked this pull request as draft
July 27, 2026 14:52
jscottmiller
force-pushed
the
scott/gateway-2-ai-gateway-role
branch
3 times, most recently
from
July 28, 2026 01:53
d2acbf6 to
2b14e56
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Enforces AI Bridge interception permissions at the AI Gateway.
Enforcement
aibridgedserver.IsAuthorizedpreviously validated only the API key and user status; recording happens under the aibridged system subject, so the initiator's own permissions were never consulted. The initiator must now be authorized to create an AI Bridge interception or the request is rejected with HTTP 403 (a generic unauthorized message; theErrNoAIGatewayAccesscause is logged server-side).The permission is carried by the site
memberrole's user-scoped grant (create/update on interceptions the holder owns, no read-back), so every user keeps AI Gateway access — the check establishes the enforcement point without changing who can use the gateway. The subject is built viahttpmw.UserRBACSubject, so custom roles are expanded and the API key's scope set applies: restricted-scope tokens cannot route AI traffic unless their scope carries the interception create permission. Server-side faults during the check surface asErrAuthorizationInternalrather than a policy denial.rolestore.ExpandandUserRBACSubjectnow accept narrow store interfaces so the aibridged server's store can satisfy them.The org-member and service-account permission floors no longer duplicate the interception grant; the site member role (held by all users, including service accounts) is the single source.
Manual verification
Verified live on a dev deployment (provider chained to dev.coder.com's gateway, model
gpt-5.6-luna):application_connectuser lacks AI Gateway accesslogged; nothing recordedapplication_connectThe scoped-token denials are fail-closed: rejection happens before interception recording and seat tracking. The plain-member row confirms the member grant alone authorizes access with no explicit role assignment.