Files
coder/coderd/rbac
J. Scott Miller 6c102cc3f3 feat: count only workspace-capable users toward license seats (#27279)
Adds permission-based license seat counting behind the
`workspace-capable-licensing` experiment. When the experiment is enabled
and a valid license carries the AI Governance add-on, the `user_limit`
feature counts only active users the RBAC engine authorizes to create a
workspace, instead of every active user. Users without workspace-create
capability ("gateway accounts", e.g. AI-Gateway-only users) no longer
consume seats.

## How it works

- A new `GetActiveUsersAuthorizationRoles` bulk query returns effective
roles (implied member roles, org default member roles) and group
memberships for every seat-eligible user (active, not deleted, not
system, not a service account), matching `GetActiveUserCount` semantics.
- `license.CountWorkspaceCapableUsers` evaluates `workspace.create`
against the any-organization object form, which covers site-wide grants,
membership grants, and org-scoped bans in one check. Evaluation is
deduplicated on a sha256 of each user's canonical subject JSON (a fixed
sentinel user ID, sorted deduplicated roles and groups), so cost scales
with unique subjects rather than user count, and every subject field
participates in both the evaluation and the key.
- The AI Governance add-on is only known after license claims are
parsed, so `Entitlements()` passes a lazy `WorkspaceCapableUserCountFn`
(following the `ManagedAgentCountFn` precedent) and
`LicensesEntitlements` resolves it when a validated add-on is present.
Each license's `user_limit` claim becomes a candidate pair of limit and
counting mode, the most favorable pair is selected (see Behavior notes),
and the selected pair's limit, entitlement, and count become the
`user_limit` feature's terms; the warnings read the same values.
`license.Entitlements` gains `logger`, `authorizer`, and `experiments`
parameters.
- All custom roles are prefetched in a single query before evaluation
(new exported `rolestore.PrefetchCustomRoles`), and each count emits one
Info log line (capable count, eligible active users, unique subjects,
elapsed) whose presence identifies the counting mode. The count is
bounded by a 60s timeout.

## Behavior notes

- Without the experiment or without the add-on, the legacy
`GetActiveUserCount` path is unchanged.
- When the mode is active, the over-limit and expired-limit warnings say
"workspace-capable users" instead of "active users", since that is what
was counted.
- With multiple licenses, each license's `user_limit` claim forms a
candidate pair of limit and counting mode (workspace-capable for add-on
licenses, all active users otherwise), and the most favorable pair is
enforced: a pair satisfied by its own count wins over any unsatisfied
one, then higher entitlement, then higher limit. One license's limit is
never combined with another license's counting mode, so a small add-on
license can neither borrow a bigger non-add-on limit nor suppress it.
- Licenses in their grace period still gate the count; it reverts to the
legacy count only on hard expiry. While the add-on exists only on
grace-period licenses, a warning tells admins the counting mode will
revert and states the legacy active-user count they will then be
measured by.
- Count errors (database failures, timeout) abort the entitlements
computation, matching the legacy count's error semantics: the refresh
fails and the caller keeps the previous entitlements rather than a
silently different count. One exception: a stored role string that fails
to parse is logged and treated as not workspace-capable instead of
failing the refresh, since authorization fails closed on such roles
anyway.
- The experiment is deliberately not in `ExperimentsSafe`.

Part of the gateway-accounts feature; no behavior changes for
deployments without the experiment.

## Stack

Part 1 of the gateway-accounts stack. Each PR builds on the previous:

1. **#27279 (this PR)**: permission-based license seat counting. Behind
the `workspace-capable-licensing` experiment and gated on the AI
Governance add-on, `user_limit` counts only users the RBAC engine
authorizes to create workspaces.
2. **#27280**: adds the `organization-ai-gateway-access` org role
carrying the AI Bridge interception permissions (extracted from the
member floors, backfilled into org default roles by migration) and
enforces it at AI Gateway authentication; bridge usage stops claiming AI
Governance seats under the experiment.
3. ~~**#27281**: gates workspace ACL grants on matching member-level
capability (each granted action only takes effect while the recipient
holds that action in the org), so workspace sharing is ineffective for
(and rejected toward) users without workspace capabilities, evaluated
live on every authorization.~~ Tabled — excluded from the
gateway-accounts MVP.

Related but independent: **#27278** hides the Workspaces page create
CTAs for users without workspace-create permission.

## Benchmarks

`BenchmarkCountWorkspaceCapableUsers` (in `usercount_bench_test.go`, run
manually with `go test ./enterprise/coderd/license/ -bench
BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$'` — never
executed by CI) measures the count across user-scale and role-diversity
shapes:

| Scenario | Users | ~Unique subjects | per count |
|---|---|---|---|
| Uniform | 1k | 4 | 8.5ms |
| Uniform | 10k | 4 | 71ms |
| Uniform | 50k | 4 | 344ms |
| ManyOrgs (100 orgs) | 10k | ~200 | 112ms |
| CustomRoles (1000 org-scoped roles) | 10k | ~1000 | 168ms |
| UniquePairs (every user a distinct subject) | 10k | ~10,000 | 2.66s |

Summary:

- **Row-side cost is ~7µs per user, linear** (role parsing, subject
canonicalization, and sha256 per row). The bulk query + subject dedupe
handles 50k users in ~350ms; extrapolated 100k ≈ 0.7s. A non-issue at
the 10-minute refresh cadence.
- **Unique subjects are the dominant axis at ~0.26ms each** (role
expansion + one any-organization rego evaluation per subject). The
worst-case scenario — every user a distinct subject — costs ~2.7s at 10k
users, extrapolating to ~13s at 50k.
- **Realistic deployments sit near the cheap rows.** Subject diversity
tracks orgs × role/group combinations, not user count; only per-user
custom roles or per-user org-membership patterns approach the worst
case.
- Caveat encountered while building the harness: the roles query's plan
depends on accurate table statistics. With stale stats (e.g. right after
a bulk user import, before autovacuum ANALYZEs), the planner picks a
nested-loop plan that re-runs the aggregation per user row — a ~300×
regression (1.08s for 1k users). Fresh statistics restore the hash-join
plan; the harness ANALYZEs after seeding, so the numbers above reflect
the healthy plan.
2026-07-27 20:43:57 -05:00
..
2026-05-20 10:46:35 +01:00

Authz

Package rbac implements Role-Based Access Control for Coder.

See USAGE.md for a hands-on approach to using this package.

Overview

Authorization defines what permission a subject has to perform actions to objects:

  • Permission is binary: yes (allowed) or no (denied).
  • Subject in this case is anything that implements interface rbac.Subject.
  • Action here is an enumerated list of actions. Actions can differ for each object type. They typically read like, Create, Read, Update, Delete, etc.
  • Object here is anything that implements rbac.Object.

Permission Structure

A permission is a rule that grants or denies access for a subject to perform an action on a object. A permission is always applied at a given level:

  • site level applies to all objects in a given Coder deployment.
  • org level applies to all objects that have an organization owner (org_owner)
  • user level applies to all objects that have an owner with the same ID as the subject.

Permissions at a higher level always override permissions at a lower level.

The effect of a permission can be:

  • positive (allows)
  • negative (denies)
  • abstain (neither allows or denies, not applicable)

Negative permissions always override positive permissions at the same level. Both negative and positive permissions override abstain at the same level.

This can be represented by the following truth table, where Y represents positive, N represents negative, and _ represents abstain:

Action Positive Negative Result
read Y _ Y
read Y N N
read _ _ _
read _ N N

Permission Representation

Permissions are represented in string format as <sign>?<level>.<object>.<id>.<action>, where:

  • negated can be either + or -. If it is omitted, sign is assumed to be +.
  • level is either site, org, or user.
  • object is any valid resource type.
  • id is any valid UUID v4.
  • id is included in the permission syntax, however only scopes may use id to specify a specific object.
  • action is typically create, read, modify, delete, but you can define other verbs as needed.

Example Permissions

  • +site.app.*.read: allowed to perform the read action against all objects of type app in a given Coder deployment.
  • -user.workspace.*.create: user is not allowed to create workspaces.

Levels

A user can be given (or deprived) a permission at several levels. Currently, those levels are:

  • Site-wide level
  • Organization level
  • User level
  • Organization member level

The site-wide level is the most authoritative. Any permission granted or denied at the side-wide level is absolute. After checking the site-wide level, depending of if the resource is owned by an organization or not, it will check the other levels.

  • If the resource is owned by an organization, the next most authoritative level is the organization level. It acts like the site-wide level, but only for resources within the corresponding organization. The user can use that permission on any resource within that organization.

    • After the organization level is the member level. This level only applies to resources that are owned by both the organization and the user.
  • If the resource is not owned by an organization, the next level to check is the user level. This level only applies to resources owned by the user and that are not owned by any organization.

                 ┌──────────┐
                 │   Site   │
                 └─────┬────┘
            ┌──────────┴───────────┐
         ┌──┤   Owned by an org?   ├──┐
         │  └──────────────────────┘  │
      ┌──┴──┐                      ┌──┴─┐
      │ Yes │                      │ No │
      └──┬──┘                      └──┬─┘
┌────────┴─────────┐            ┌─────┴────┐
│   Organization   │            │   User   │
└────────┬─────────┘            └──────────┘
   ┌─────┴──────┐
   │   Member   │
   └────────────┘

Roles

A role is a set of permissions. When evaluating a role's permission to form an action, all the relevant permissions for the role are combined at each level. Permissions at a higher level override permissions at a lower level.

The following tables show the per-level role evaluation. Y indicates that the role provides positive permissions, N indicates the role provides negative permissions, and indicates the role does not provide positive or negative permissions. YN indicates that the value in the cell does not matter for the access result. The table varies depending on if the resource belongs to an organization or not.

If the resource is owned by an organization, such as a template or a workspace:

Role (example) Site Org OrgMember Result
site-admin Y YN_ YN_ Y
negative-site-permission N YN_ YN_ N
org-admin _ Y YN_ Y
non-org-member _ N YN_ N
member-owned _ _ Y Y
not-member-owned _ _ N N
unauthenticated _ _ _ N

If the resource is not owned by an organization:

Role (example) Site User Result
site-admin Y YN_ Y
negative-site-permission N YN_ N
user-owned _ Y Y
not-user-owned _ N N
unauthenticated _ _ N

Scopes

Scopes can restrict a given set of permissions. The format of a scope matches a role with the addition of a list of resource ids. For a authorization call to be successful, the subject's roles and the subject's scopes must both allow the action. This means the resulting permissions is the intersection of the subject's roles and the subject's scopes.

An example to give a readonly token is to grant a readonly scope across all resources +site.*.*.read. The intersection with the user's permissions will be the readonly set of their permissions.

Resource IDs

There exists use cases that require specifying a specific resource. If resource IDs are allowed in the roles, then there is an unbounded set of resource IDs that be added to an "allow_list", as the number of roles a user can have is unbounded. This also adds a level of complexity to the role evaluation logic that has large costs at scale.

The use case for specifying this type of permission in a role is limited, and does not justify the extra cost. To solve this for the remaining cases (eg. workspace agent tokens), we can apply an allow_list on a scope. For most cases, the allow_list will just be ["*"] which means the scope is allowed to be applied to any resource. This adds negligible cost to the role evaluation logic and 0 cost to partial evaluations.

Example of a scope for a workspace agent token, using an allow_list containing a single resource id.

{
	"scope": {
		"name": "workspace_agent",
		"display_name": "Workspace_Agent",
		// The ID of the given workspace the agent token correlates to.
		"allow_list": ["10d03e62-7703-4df5-a358-4f76577d4e2f"],
		"site": [/* ... perms ... */],
		"org": {/* ... perms ... */},
		"user": [/* ... perms ... */]
	}
}

OPA (Open Policy Agent)

Open Policy Agent (OPA) is an open source tool used to define and enforce policies. Policies are written in a high-level, declarative language called Rego. Coders RBAC rules are defined in the policy.rego file under the authz package.

When OPA evaluates policies, it binds input data to a global variable called input. In the rbac package, this structured data is defined as JSON and contains the action, object and subject (see regoInputValue in astvalue.go). OPA evaluates whether the subject is allowed to perform the action on the object across three levels: site, org, and user. This is determined by the final rule allow, which aggregates the results of multiple rules to decide if the user has the necessary permissions. Similarly to the input, OPA produces structured output data, which includes the allow variable as part of the evaluation result. Authorization succeeds only if allow explicitly evaluates to true. If no allow is returned, it is considered unauthorized. To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs.

Application and Database Integration

  • rbac/authz.go Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation.
  • database/dbauthz/dbauthz.go Database layer integration: wraps the database layer with authorization checks to enforce access control.

There are two types of evaluation in OPA:

  • Full evaluation: Produces a decision that can be enforced. This is the default evaluation mode, where OPA evaluates the policy using input data that contains all known values and returns output data with the allow variable.
  • Partial evaluation: Produces a new policy that can be evaluated later when the unknowns become known. This is an optimization in OPA where it evaluates as much of the policy as possible without resolving expressions that depend on unknown values from the input. To learn more about partial evaluation, see this OPA blog post.

Application of Full and Partial evaluation in rbac package:

  • Full Evaluation is handled by the RegoAuthorizer.Authorize() method in authz.go. This method determines whether a subject (user) can perform a specific action on an object. It performs a full evaluation of the Rego policy, which returns the allow variable to decide whether access is granted (true) or denied (false or undefined).
  • Partial Evaluation is handled by the RegoAuthorizer.Prepare() method in authz.go. This method compiles OPAs partial evaluation queries into SQL WHERE clauses. These clauses are then used to enforce authorization directly in database queries, rather than in application code.

Authorization Patterns:

  • Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via Authorize().
  • Authorize-while-fetching: Partial evaluation via Prepare() is used to inject SQL filters directly into queries, allowing efficient authorization of many objects of the same type. dbauthz methods that enforce authorization directly in the SQL query are prefixed with Authorized, for example, GetAuthorizedWorkspaces.

Testing

  • OPA Playground: https://play.openpolicyagent.org/
  • OPA CLI (opa eval): useful for experimenting with different inputs and understanding how the policy behaves under various conditions. opa eval returns the constraints that must be satisfied for a rule to evaluate to true.
    • opa eval requires an input.json file containing the input data to run the policy against. You can generate this file using the gen_input.go script. Note: the script currently produces a fixed input. You may need to tweak it for your specific use case.

Full Evaluation

opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json

This command fully evaluates the policy in the policy.rego file using the input data from input.json, and returns the result of the allow variable:

  • data.authz.allow accesses the allow rule within the authz package.
  • data.authz on its own would return the entire output object of the package.

This command answers the question: “Is the user allowed?”

Partial Evaluation

opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.id --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json

This command performs a partial evaluation of the policy, specifying a set of unknown input parameters. The result is a set of partial queries that can be converted into SQL WHERE clauses and injected into SQL queries.

This command answers the question: “What conditions must be met for the user to be allowed?”

Benchmarking

Benchmark tests to evaluate the performance of full and partial evaluation can be found in authz_test.go. You can run these tests with the -bench flag, for example:

go test -bench=BenchmarkRBACFilter -run=^$

To capture memory and CPU profiles, use the following flags:

  • -memprofile memprofile.out
  • -cpuprofile cpuprofile.out

The script benchmark_authz.sh runs the authz benchmark tests on the current Git branch or compares benchmark results between two branches using benchstat. benchstat compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences.

  • To run benchmark on the current branch:

    benchmark_authz.sh --single
    
  • To compare benchmarks between 2 branches:

    benchmark_authz.sh --compare main prebuild_policy