🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: enforce AI Bridge interception permission at the AI Gateway - #27530

Draft
jscottmiller wants to merge 39 commits into
mainfrom
scott/gateway-3-ai-gateway-authz
Draft

feat: enforce AI Bridge interception permission at the AI Gateway#27530
jscottmiller wants to merge 39 commits into
mainfrom
scott/gateway-3-ai-gateway-authz

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Enforces AI Bridge interception permissions at the AI Gateway.

Enforcement

aibridgedserver.IsAuthorized previously 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; the ErrNoAIGatewayAccess cause is logged server-side).

The permission is carried by the site member role'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 via httpmw.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 as ErrAuthorizationInternal rather than a policy denial. rolestore.Expand and UserRBACSubject now 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):

Identity Token scope Result
Admin full 200; interception recorded
Plain member (no roles) full 200; interception recorded
Service account full 200; interception recorded under the SA
Member application_connect 403; user lacks AI Gateway access logged; nothing recorded
Service account application_connect 403; denial logged for the SA; nothing recorded

The 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.

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.
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.
…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.
- 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.
…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.
@coder-tasks

coder-tasks Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/ai-coder/ai-gateway/auth.md - Authentication now requires the initiator to be authorized to create an AI Bridge interception, so token scopes matter. The page currently implies any Coder token works ("the same Coder API token that a user already uses against the rest of the Coder API"). Add that a scoped token must carry aibridge_interception:create (the default all scope does), and that otherwise the request is rejected with 403 Forbidden with the denial logged server-side. Worth calling out in Create a Coder API token since that section recommends short-lived automation/CI tokens, and in the headless/service-account path.
  • docs/admin/users/sessions-tokens.md - The API key scopes section (line ~124) lists common scope examples but does not mention that restricted-scope tokens lose AI Gateway access. Add a note pointing at the AI Gateway auth page so users creating narrow-scoped tokens for automation don't hit an unexplained 403.

No docs action needed for the rolestore.Expand / httpmw.UserRBACSubject interface narrowing, the RBAC permission-floor consolidation (net-effective permissions are unchanged for members and service accounts), or the organization-workspace-access role description in site/src/modules/roles/index.ts (the role itself is introduced elsewhere in the stack, and docs/admin/users/groups-roles.md does not enumerate built-in role descriptions).


Automated review via Coder Agents

@jscottmiller
jscottmiller marked this pull request as draft July 27, 2026 14:52
@jscottmiller
jscottmiller force-pushed the scott/gateway-2-ai-gateway-role branch 3 times, most recently from d2acbf6 to 2b14e56 Compare July 28, 2026 01:53
Base automatically changed from scott/gateway-2-ai-gateway-role to main July 28, 2026 02:05
@jscottmiller jscottmiller added the experimental Changes that might not necessarily be merged, until its approved to proceed with. label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Changes that might not necessarily be merged, until its approved to proceed with.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant