feat(site): hide workspace resources when lacking workspace-create permission - #27278
Conversation
c2204a6 to
030076a
Compare
796e515 to
789bca4
Compare
3886359 to
b6e866b
Compare
…t workspace-create permission Adds a createWorkspaceInAnyOrganization site-wide permission check (workspace create, any organization, owned by self) to the shared permissions.json and hides the New workspace button when it is false. The empty state drops its create CTAs for the same users and explains that workspace access requires administrator-granted permission. Gateway accounts, which lack organization-workspace-access, no longer see a create path that would only fail authorization.
…ions withAuthProvider previously seeded the authcheck query with the raw parameters.permissions object, so any permission key a story did not list was undefined and therefore falsy. Every new key added to site/permissions.json silently rendered page-level stories as if the viewer lacked that permission; the WorkspacesPage Empty story broke this way when createWorkspaceInAnyOrganization was added. parameters.permissions is now a partial override merged over MockPermissions, so stories state only the restrictions they are about.
…rmission to view page The Workspaces page hides the New workspace button from users without create permission, but the creation form itself was reachable by direct URL; a permission-less user could fill it out and only hit a 403 on submit. Add an org-scoped createWorkspaceForUserID check to the page's existing authorization batch and wrap the view in RequirePermission, matching how other restricted pages deny access. Users who can create workspaces for others (createWorkspaceForAny) still see the form. An errored permission check is not treated as a denial; the request error surfaces in the view instead.
…fore filter empty state A user without create permission arriving with an active filter in the URL saw "No results matched your search" instead of the no-permission message. Check canCreateWorkspace before isUsingFilter in WorkspacesEmpty. Add story coverage for the button being hidden while workspaces are listed, and for the empty-state branch priority; make canCreateWorkspace explicit in the empty-state stories instead of relying on the meta default, and drop a no-op canCreateTemplate arg.
… createWorkspace Sibling checks with the same object shape encode scope in the check definition rather than the key name (createChat is also any_org + owner_id: me), so follow that convention.
…orkspace SSR check Add PermissionDenied (both create checks false -> RequirePermission dialog, no form) and CanCreateForOthersOnly (createWorkspaceForAny alone still shows the form) stories with play assertions. Assert the createWorkspace entry in TestRenderPermissionsResolvesMe: true for an org member, false for a user with no org membership.
Match "You don't have permission to view this page" exactly instead of a fragment, so the assertion fails if the dialog copy diverges.
…arameters story mock The page only leaves its loading state after the socket's onOpen callback runs: onOpen sends the initial parameters and records the response ID the form must wait for (initId stays NaN until then, which keeps isInitializing true). The story mock predates that contract and only fired onMessage, so every story in the file hung on the loader and their play assertions timed out, including in Chromatic. Fire onOpen before delivering the server's initial id: -1 response, and report an OPEN readyState so sendMessage works. Fixes all six stories in the file, including the four external-auth stories broken since the contract change.
…ator in stories Replace the file's bespoke templateVersionDynamicParameters spy with the withWebSocket decorator and declared socket events, the same mechanism WorkspaceParametersPage stories use. The declared events are open followed by the server's initial id: -1 response.
…ions Merging over MockPermissions changed the rendered persona of every story that encoded a restriction by omitting a permission key (for example WorkspaceWildcardWarning's WithoutEditPermission rendered the with-permission copy, and the TaskPage sidebar gained the viewAllUsers combobox), producing hundreds of Chromatic diffs. Merge over MockNoPermissions instead: unspecified keys are explicitly false, matching the previous seeded-raw behavior while keeping the object complete, and stories that depend on a granted permission must list it - as WorkspacesPage now does for createWorkspace.
Both merge defaults changed rendering or reviewer-visible snapshot behavior across unrelated stories; seed parameters.permissions raw exactly as before. Stories cover missing keys explicitly instead (WorkspacesPage sets createWorkspace: true).
…mission state Restore the filter empty state's priority in WorkspacesEmpty: an active filter that matched nothing shows "no results" regardless of create permission, since the user may own workspaces the filter excluded. Repoint the CannotCreateWorkspaceWithFilter story at the restored order. Drop createWorkspaceForAny from the CreateWorkspacePage guard and remove the CanCreateForOthersOnly story: an org- or site-scoped grant that satisfies owner_id "*" is owner-agnostic, so it also satisfies the owner_id: me check - createWorkspaceForAny cannot be true while createWorkspaceForUserID is false.
b6e866b to
391403e
Compare
| autoCreateError || | ||
| loadFormDataError || | ||
| autoCreateWorkspaceMutation.error | ||
| <RequirePermission |
There was a problem hiding this comment.
I think this guard needs to happen before the auto-create flow, not just around the form. With ?mode=auto, a denied user can still see the consent dialog and, after confirming, call autoCreateWorkspace, which gets us back to the 403 flow this is meant to avoid. Could we gate both showAutoCreateConsent and autoCreateReady on createWorkspaceForUserID? A denied auto-mode story would be nice too.
There was a problem hiding this comment.
TIL about auto mode. Done.
| // An errored permission check is not a denial; render the | ||
| // page so the request error is surfaced instead. | ||
| isFeatureVisible={ | ||
| permissionsQuery.isError || |
There was a problem hiding this comment.
I don't think this error path works as intended. If permissionsQuery.isError is true, we render the page, but permissionsQuery.data is still undefined and gets cast below. CreateWorkspacePageView immediately reads permissions.createWorkspaceForAny, so I think this crashes instead of surfacing the query error. Consider rendering an explicit error state, or only render the view once permission data exists
There was a problem hiding this comment.
Nice catch. CreateWorkspacePageView takes an error param (which will take on the permissionQuery.error value), but this only adds an error message and doesn't short-circuit the component's behavior in a meaningful way. It looks like there is a similar issue for errors coming from the template query. I'll gate the page using loadFormDataError (carries template error || permission error), following the conventions of WorkspacePage.
There was a problem hiding this comment.
Correction: if the template error'd out, it would hang on the loader.
…permission check errors The RequirePermission wrapper only covered the form: with ?mode=auto a denied user still saw the consent dialog and could trigger the auto-create request. Require createWorkspaceForUserID in autoCreateReady and showAutoCreateConsent, so a denied auto-mode visit goes straight to the permission dialog. The isError passthrough rendered the view with undefined permission data, which the view reads unconditionally, crashing instead of surfacing the error. Render an ErrorAlert for a failed permission check instead. Cover both in unit tests plus the denied form-mode dialog, and add a PermissionDeniedAutoMode story.
… instead of the loader A failed template query left the page on its loader forever: shouldShowLoader includes !templateQuery.data, and loadFormDataError was only passed into the view, which never rendered. Check loadFormDataError before the loader and render a page-level ErrorAlert, covering both template and permission query failures, and drop the now-unreachable loadFormDataError from the view's error chain.
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.
Under the new `ai-gateway-seat-exclusion` experiment, AI Bridge usage stops counting toward AI Governance seats. ## Seat recording Under the 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. This experiment is independent of `workspace-capable-licensing` (#27279) so the two licensing behaviors can be enabled separately. Task workspace builds still claim AI Governance seats. ## Manual verification Verified live on a dev deployment (provider chained to dev.coder.com's gateway, model `gpt-5.6-luna`): with the experiment off, the first bridge request from each identity type (admin, plain member, service account) wrote an `ai_seat_state` row (`aibridge` reason); with it on, requests recorded interceptions but left seat state untouched — no new rows, and existing rows' `last_used_at` did not advance. Part of the gateway-accounts feature. ## Stack Part 2 of the gateway-accounts stack: 1. **#27279**: 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. **This PR**: stops AI Bridge usage from claiming AI Governance seats under the new `ai-gateway-seat-exclusion` experiment. 3. ~~**#27281**: adds a `use_shared` capability precondition for workspace ACL grants, so workspace sharing is ineffective for (and rejected toward) users without workspace capabilities, evaluated live on every authorization.~~ This will be done in follow-up work when we have time to look into the performance impact. Related but independent: **#27278** hides the Workspaces page create CTAs for users without workspace-create permission.
dylanhuff-at-coder
left a comment
There was a problem hiding this comment.
Approved but I am not a UI expert so YMMV
Context: experiment
minimum-implicit-memberadded the ability to set the default member-roles at a per-organization level. This, along with the related PR stacked listed below, will be used to enable "gateway accounts", which are accounts that are entitled to use the AI Gateway but not create or use workspaces. The Workspaces tab is intentionally left visible for now.Hides the "New workspace" button and the empty-state creation CTA on the Workspaces page for users who cannot create a workspace in any organization, and guards the creation page itself.
Adds a shared
createWorkspaceauthorization check (workspaceresource,createaction,owner_id: me,any_org: true) tosite/permissions.jsonand threads the result throughWorkspacesPageView,WorkspacesTable, andWorkspacesEmpty. Users without the permission see an empty state explaining they don't have permission to create workspaces instead of a dead-end CTA. The create CTAs on the Templates pages were already gated by per-organization checks; this brings the Workspaces page in line.CreateWorkspacePageis also gated: it adds an org-scopedcreateWorkspaceForUserIDcheck to its existing authorization batch and wraps the view inRequirePermission, so a direct URL shows the standard denial dialog instead of a form that 403s on submit. Users who can create workspaces for others (createWorkspaceForAny) still see the form.To see this behavior, enable the experiment. As an admin, visit Organization -> Roles, and remove "Organization Workspace Access" from the default roles. Login as a user that is not granted workspace access via a member role.
Storybook coverage:
CannotCreateWorkspace(empty state + hidden button),CannotCreateWorkspaceWithWorkspaces(button hidden while the table renders),CannotCreateWorkspaceWithFilter(pins the filter empty state's priority over the no-permission one), andPermissionDeniedfor the CreateWorkspacePage gate. The Go SSR permissions test also asserts the newcreateWorkspaceentry.Stack
This PR is independent but related to the gateway-accounts stack:
permission-based-licensingexperiment and gated on the AI Governance add-on,user_limitcounts only users the RBAC engine authorizes to create workspaces.organization-ai-gateway-accessorg 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.feat: gate workspace ACL grants on matching member capability #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.This PR (#27278) stands alone: it hides the Workspaces page create CTAs for users without workspace-create permission and can merge in any order.