From a4947348054b5ea2ae8c96e9f9f036059f5be1a0 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 7 Jul 2026 21:16:38 +0000 Subject: [PATCH 01/41] feat: count only workspace-capable users toward license seats 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. --- coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbauthz/dbauthz_test.go | 4 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 + coderd/database/modelmethods.go | 12 + coderd/database/querier.go | 6 + coderd/database/queries.sql.go | 84 +++++ coderd/database/queries/users.sql | 54 +++ codersdk/deployment.go | 28 +- docs/reference/api/schemas.md | 6 +- enterprise/coderd/coderd.go | 5 +- enterprise/coderd/license/license.go | 66 +++- enterprise/coderd/license/license_test.go | 78 ++--- enterprise/coderd/license/usercount.go | 117 +++++++ enterprise/coderd/license/usercount_test.go | 344 ++++++++++++++++++++ site/src/api/typesGenerated.ts | 2 + 18 files changed, 784 insertions(+), 60 deletions(-) create mode 100644 enterprise/coderd/license/usercount.go create mode 100644 enterprise/coderd/license/usercount_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1c1f1946b91..af83a9fb9f3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20125,6 +20125,7 @@ const docTemplate = `{ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", + "permission-based-licensing", "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" @@ -20140,6 +20141,7 @@ const docTemplate = `{ "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentPermissionBasedLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, @@ -20153,6 +20155,7 @@ const docTemplate = `{ "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", + "Counts only users holding the workspace-create permission toward the license seat limit.", "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", "Enables virtual desktop and computer use provider for agents." @@ -20167,6 +20170,7 @@ const docTemplate = `{ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", + "ExperimentPermissionBasedLicensing", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 78a0c965ebf..1ef1412b84a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18277,6 +18277,7 @@ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", + "permission-based-licensing", "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" @@ -18292,6 +18293,7 @@ "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentPermissionBasedLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, @@ -18305,6 +18307,7 @@ "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", + "Counts only users holding the workspace-create permission toward the license seat limit.", "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", "Enables virtual desktop and computer use provider for agents." @@ -18319,6 +18322,7 @@ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", + "ExperimentPermissionBasedLicensing", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6d24264d470..861083e8ff4 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2955,6 +2955,13 @@ func (q *querier) GetActiveUserCount(ctx context.Context, includeSystem bool) (i return q.db.GetActiveUserCount(ctx, includeSystem) } +func (q *querier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]database.GetActiveUsersAuthorizationRolesRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetActiveUsersAuthorizationRoles(ctx) +} + func (q *querier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { // This is a system-only function. if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8f98fd7d7bc..a8f54c8005f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5038,6 +5038,10 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes() check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0)) })) + s.Run("GetActiveUsersAuthorizationRoles", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return([]database.GetActiveUsersAuthorizationRolesRow{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) + })) s.Run("GetAuthorizationUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) dbm.EXPECT().GetAuthorizationUserRoles(gomock.Any(), u.ID).Return(database.GetAuthorizationUserRolesRow{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 07d33217470..119764c5816 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1281,6 +1281,14 @@ func (m queryMetricsStore) GetActiveUserCount(ctx context.Context, includeSystem return r0, r1 } +func (m queryMetricsStore) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]database.GetActiveUsersAuthorizationRolesRow, error) { + start := time.Now() + r0, r1 := m.s.GetActiveUsersAuthorizationRoles(ctx) + m.queryLatencies.WithLabelValues("GetActiveUsersAuthorizationRoles").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetActiveUsersAuthorizationRoles").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { start := time.Now() r0, r1 := m.s.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9a255e2df74..b16a20f91f0 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2233,6 +2233,21 @@ func (mr *MockStoreMockRecorder) GetActiveUserCount(ctx, includeSystem any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUserCount", reflect.TypeOf((*MockStore)(nil).GetActiveUserCount), ctx, includeSystem) } +// GetActiveUsersAuthorizationRoles mocks base method. +func (m *MockStore) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]database.GetActiveUsersAuthorizationRolesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveUsersAuthorizationRoles", ctx) + ret0, _ := ret[0].([]database.GetActiveUsersAuthorizationRolesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveUsersAuthorizationRoles indicates an expected call of GetActiveUsersAuthorizationRoles. +func (mr *MockStoreMockRecorder) GetActiveUsersAuthorizationRoles(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUsersAuthorizationRoles", reflect.TypeOf((*MockStore)(nil).GetActiveUsersAuthorizationRoles), ctx) +} + // GetActiveWorkspaceBuildsByTemplateID mocks base method. func (m *MockStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 76ca27166ba..3576c98276a 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -876,6 +876,18 @@ func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) return names, nil } +func (r GetActiveUsersAuthorizationRolesRow) RoleNames() ([]rbac.RoleIdentifier, error) { + names := make([]rbac.RoleIdentifier, 0, len(r.Roles)) + for _, role := range r.Roles { + value, err := rbac.RoleNameFromString(role) + if err != nil { + return nil, xerrors.Errorf("convert role %q: %w", role, err) + } + names = append(names, value) + } + return names, nil +} + func (k CryptoKey) ExpiresAt(keyDuration time.Duration) time.Time { return k.StartsAt.Add(keyDuration).UTC() } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7579a25c7dc..fb0e8ab0398 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -341,6 +341,12 @@ type sqlcQuerier interface { GetActiveChatsByAgentID(ctx context.Context, agentID uuid.UUID) ([]Chat, error) GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) + // Returns the authorization roles (site and org-scoped, including implied + // member roles and organization default roles) plus group memberships for + // every user eligible for license seat counting: active, not deleted, and + // neither a system user nor a service account. Used by permission-based + // license seat counting to evaluate workspace-create capability. + GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) // For PG Coordinator HTMLDebug GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f4ae41eb3c2..2bf11e7544f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30175,6 +30175,90 @@ func (q *sqlQuerier) GetActiveUserCount(ctx context.Context, includeSystem bool) return count, err } +const getActiveUsersAuthorizationRoles = `-- name: GetActiveUsersAuthorizationRoles :many +SELECT + id, + array_cat( + -- All users are members + array_append(users.rbac_roles, 'member'), + ( + SELECT + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_roles || ':' || organization_members.organization_id::text) + FROM + organization_members + JOIN organizations ON organizations.id = organization_members.organization_id, + -- All org members get an implied organization-member role for + -- their orgs. Service accounts are excluded by the outer WHERE, + -- so the organization-service-account case does not apply here. + -- + -- organizations.default_org_member_roles is unioned in so changes + -- to org defaults propagate on the next entitlement refresh. + unnest( + array_cat( + array_append(roles, 'organization-member'), + organizations.default_org_member_roles + ) + ) AS org_roles + WHERE + user_id = users.id + ) + ) :: text[] AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) :: text[] AS groups +FROM + users +WHERE + users.status = 'active'::user_status + AND users.deleted = false + AND users.is_system = false + AND users.is_service_account = false +` + +type GetActiveUsersAuthorizationRolesRow struct { + ID uuid.UUID `db:"id" json:"id"` + Roles []string `db:"roles" json:"roles"` + Groups []string `db:"groups" json:"groups"` +} + +// Returns the authorization roles (site and org-scoped, including implied +// member roles and organization default roles) plus group memberships for +// every user eligible for license seat counting: active, not deleted, and +// neither a system user nor a service account. Used by permission-based +// license seat counting to evaluate workspace-create capability. +func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) { + rows, err := q.db.QueryContext(ctx, getActiveUsersAuthorizationRoles) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetActiveUsersAuthorizationRolesRow + for rows.Next() { + var i GetActiveUsersAuthorizationRolesRow + if err := rows.Scan(&i.ID, pq.Array(&i.Roles), pq.Array(&i.Groups)); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAuthorizationUserRoles = `-- name: GetAuthorizationUserRoles :one SELECT -- username and email are returned just to help for logging purposes diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 3c79b405225..711fb0e41bb 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -653,6 +653,60 @@ FROM WHERE users.id = @user_id; +-- name: GetActiveUsersAuthorizationRoles :many +-- Returns the authorization roles (site and org-scoped, including implied +-- member roles and organization default roles) plus group memberships for +-- every user eligible for license seat counting: active, not deleted, and +-- neither a system user nor a service account. Used by permission-based +-- license seat counting to evaluate workspace-create capability. +SELECT + id, + array_cat( + -- All users are members + array_append(users.rbac_roles, 'member'), + ( + SELECT + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_roles || ':' || organization_members.organization_id::text) + FROM + organization_members + JOIN organizations ON organizations.id = organization_members.organization_id, + -- All org members get an implied organization-member role for + -- their orgs. Service accounts are excluded by the outer WHERE, + -- so the organization-service-account case does not apply here. + -- + -- organizations.default_org_member_roles is unioned in so changes + -- to org defaults propagate on the next entitlement refresh. + unnest( + array_cat( + array_append(roles, 'organization-member'), + organizations.default_org_member_roles + ) + ) AS org_roles + WHERE + user_id = users.id + ) + ) :: text[] AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) :: text[] AS groups +FROM + users +WHERE + users.status = 'active'::user_status + AND users.deleted = false + AND users.is_system = false + AND users.is_service_account = false; + -- name: UpdateUserQuietHoursSchedule :one UPDATE users diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 4a749c21fc5..ce5e594bc9c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5250,18 +5250,19 @@ type Experiment string const ( // Add new experiments here! - ExperimentExample Experiment = "example" // This isn't used for anything. - ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. - ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. - ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. - ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. - ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. - ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. - ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. - ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. - ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. - ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. - ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. + ExperimentExample Experiment = "example" // This isn't used for anything. + ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. + ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. + ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. + ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. + ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. + ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. + ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. + ExperimentPermissionBasedLicensing Experiment = "permission-based-licensing" // Counts only users holding the workspace-create permission toward the license seat limit. + ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. + ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. + ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. ) func (e Experiment) DisplayName() string { @@ -5284,6 +5285,8 @@ func (e Experiment) DisplayName() string { return "NATS Pubsub" case ExperimentMinimumImplicitMember: return "Gateway Accounts (minimum implicit member)" + case ExperimentPermissionBasedLicensing: + return "Permission-Based Licensing" case ExperimentAIGatewayCostControl: return "AI Gateway Cost Control" case ExperimentChatAdvisor: @@ -5309,6 +5312,7 @@ var ExperimentsKnown = Experiments{ ExperimentNATSPubsub, ExperimentWorkspaceBuildUpdates, ExperimentMinimumImplicitMember, + ExperimentPermissionBasedLicensing, ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c000a0cbffe..2d1fd49f93f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7242,9 +7242,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | +| Value(s) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `permission-based-licensing`, `workspace-build-updates`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index a38b7ee830c..36598bf5a3d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -953,7 +953,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, codersdk.FeatureAIBridge: api.DeploymentValues.AI.BridgeConfig.Enabled.Value(), - }) + }, + api.AGPL.HTTPAuth.Authorizer, + api.AGPL.Experiments, + ) if err != nil { return codersdk.Entitlements{}, err } diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 8092e5f6258..cd937a0480f 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -31,6 +32,8 @@ func Entitlements( externalAuthCount int, keys map[string]ed25519.PublicKey, enablements map[codersdk.FeatureName]bool, + authorizer rbac.Authorizer, + experiments codersdk.Experiments, ) (codersdk.Entitlements, error) { now := time.Now() @@ -46,6 +49,19 @@ func Entitlements( return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err) } + // Permission-based licensing counts only users the RBAC engine + // authorizes to create workspaces. Users without workspace-create + // capability ("gateway accounts") do not consume seats. The count is + // resolved lazily by LicensesEntitlements, and only when a valid + // license carries the AI Governance addon; deployments without the + // addon always use the plain active user count. + var workspaceCapableUserCountFn WorkspaceCapableUserCountFn + if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { + workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { + return CountWorkspaceCapableUsers(ctx, db, authorizer) + } + } + // nolint:gocritic // Getting active AI seat count is a system function. activeAISeatCount, err := db.GetActiveAISeatCount(dbauthz.AsSystemRestricted(ctx)) if err != nil { @@ -64,11 +80,12 @@ func Entitlements( } entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ - ActiveUserCount: activeUserCount, - ActiveAISeatCount: activeAISeatCount, - ReplicaCount: replicaCount, - ExternalAuthCount: externalAuthCount, - ExternalTemplateCount: int64(len(externalTemplates)), + ActiveUserCount: activeUserCount, + ActiveAISeatCount: activeAISeatCount, + ReplicaCount: replicaCount, + ExternalAuthCount: externalAuthCount, + ExternalTemplateCount: int64(len(externalTemplates)), + WorkspaceCapableUserCountFn: workspaceCapableUserCountFn, ManagedAgentCountFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { // This is not super accurate, as the start and end times will be // truncated to the date in UTC timezone. This is an optimization @@ -103,10 +120,18 @@ type FeatureArguments struct { // state of the world, but a count between two points in time determined by // the licenses. ManagedAgentCountFn ManagedAgentCountFn + // WorkspaceCapableUserCountFn returns the number of active users the + // RBAC engine authorizes to create workspaces. It is invoked only when + // a valid license carries the AI Governance addon; the result then + // replaces ActiveUserCount for the user_limit feature and its + // warnings. May be nil, in which case ActiveUserCount is always used. + WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn } type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error) +type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) + // LicensesEntitlements returns the entitlements for licenses. Entitlements are // merged from all licenses and the highest entitlement is used for each feature. // Arguments: @@ -131,6 +156,11 @@ func LicensesEntitlements( // suppress the soft warning for AI Bridge GA. hasExplicitAIBridgeEntitlement := false + // Track whether any valid license carries the AI Governance addon with + // its dependencies satisfied. This gates permission-based seat + // counting below. + hasAIGovernanceAddon := false + // Default all entitlements to be disabled. entitlements := codersdk.Entitlements{ Features: map[codersdk.FeatureName]codersdk.Feature{ @@ -372,6 +402,9 @@ func LicensesEntitlements( // Ignore the addon and don't add any features. continue } + if addon == codersdk.AddonAIGovernance { + hasAIGovernanceAddon = true + } for _, featureName := range addon.Features() { if _, exists := addonFeatures[featureName]; !exists { addonFeatures[featureName] = codersdk.Feature{ @@ -386,6 +419,29 @@ func LicensesEntitlements( } } + // Permission-based seat counting. When a valid license carries the AI + // Governance addon, only workspace-capable users consume user_limit + // seats; gateway accounts (users without workspace-create) are free. + // The resolved count overwrites featureArguments.ActiveUserCount, + // which the user_limit feature's Actual pointer aliases, so both the + // feature value and the over-limit warnings below observe it. + if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { + capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) + if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { + // If the context is canceled, we want to bail the entire + // LicensesEntitlements call. + return entitlements, xerrors.Errorf("count workspace capable users: %w", err) + } + if err != nil { + // Fall back to the legacy active user count rather than failing + // the entitlements computation. + entitlements.Errors = append(entitlements.Errors, + fmt.Sprintf("Error counting workspace-capable users: %s", err.Error())) + } else { + featureArguments.ActiveUserCount = capableCount + } + } + // Now the license specific warnings and errors are added to the entitlements. // Add a single warning if we are currently in the license validity period diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 10dea231cd8..f41c26c2dfd 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -33,7 +33,7 @@ func TestEntitlements(t *testing.T) { t.Run("Defaults", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -45,7 +45,7 @@ func TestEntitlements(t *testing.T) { t.Run("Always return the current user count", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -58,7 +58,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -86,7 +86,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -110,7 +110,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -137,7 +137,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -166,7 +166,7 @@ func TestEntitlements(t *testing.T) { Exp: time.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -202,7 +202,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be generated. - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -230,7 +230,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be suppressed. - entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -261,7 +261,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Should generate a warning. - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -289,7 +289,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should still be generated. - entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -315,7 +315,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -344,7 +344,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -364,7 +364,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -434,7 +434,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -462,7 +462,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(60 * 24 * time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -485,7 +485,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -501,7 +501,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -549,7 +549,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, licenseOptions), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -600,7 +600,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -621,7 +621,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -654,7 +654,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -688,7 +688,7 @@ func TestEntitlements(t *testing.T) { ExpiresAt: dbtime.Now().Add(time.Hour), }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -714,7 +714,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleReplicasNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -734,7 +734,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, - }) + }, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -757,7 +757,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, - }) + }, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Warnings, 1) @@ -767,7 +767,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleGitAuthNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -787,7 +787,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, - }) + }, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -810,7 +810,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, - }) + }, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Warnings, 1) @@ -875,7 +875,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -993,7 +993,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1063,7 +1063,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1126,7 +1126,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1186,7 +1186,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2163,7 +2163,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2195,7 +2195,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2233,7 +2233,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2264,7 +2264,7 @@ func TestAIGovernanceAddon(t *testing.T) { Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2297,7 +2297,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go new file mode 100644 index 00000000000..c2af3b55490 --- /dev/null +++ b/enterprise/coderd/license/usercount.go @@ -0,0 +1,117 @@ +package license + +import ( + "context" + "sort" + "strings" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" +) + +// CountWorkspaceCapableUsers returns the number of active users the RBAC +// engine authorizes to create a workspace, either in one of the +// organizations they belong to or in any organization via a site-wide +// role such as owner. Users without workspace-create capability ("gateway +// accounts") do not consume license seats. System users and service +// accounts are excluded by the underlying query, matching +// GetActiveUserCount. +func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authorizer rbac.Authorizer) (int64, error) { + //nolint:gocritic // Counting licensed seats is a system function. + rows, err := db.GetActiveUsersAuthorizationRoles(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + return 0, xerrors.Errorf("get active users authorization roles: %w", err) + } + + // Users with identical role and group sets always produce the same + // authorization outcome because the subject ID and the object owner + // are the same user in every check. Deduplicate on that signature so + // evaluation cost scales with the number of unique role sets, not + // the number of users. + capableBySignature := make(map[string]bool) + var count int64 + for _, row := range rows { + sig := authorizationSignature(row) + capable, ok := capableBySignature[sig] + if !ok { + capable, err = canCreateWorkspace(ctx, db, authorizer, row) + if err != nil { + return 0, xerrors.Errorf("evaluate workspace-create for user %s: %w", row.ID, err) + } + capableBySignature[sig] = capable + } + if capable { + count++ + } + } + return count, nil +} + +// canCreateWorkspace reports whether the RBAC engine authorizes the user +// to create a workspace they own, checked against every organization the +// user is a member of plus the any-organization form that site-wide roles +// satisfy regardless of org membership. +func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { + roleNames, err := row.RoleNames() + if err != nil { + return false, xerrors.Errorf("expand role names: %w", err) + } + + //nolint:gocritic // Expanding custom roles requires system access. + roles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, roleNames) + if err != nil { + return false, xerrors.Errorf("expand roles: %w", err) + } + + subject := rbac.Subject{ + Type: rbac.SubjectTypeUser, + ID: row.ID.String(), + Roles: roles, + Groups: row.Groups, + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + + // Site-wide grants (e.g. the owner role) authorize workspace creation + // in any organization, independent of org membership. This also covers + // users who belong to zero organizations. + if authorizer.Authorize(ctx, subject, policy.ActionCreate, + rbac.ResourceWorkspace.AnyOrganization().WithOwner(subject.ID)) == nil { + return true, nil + } + + seen := make(map[uuid.UUID]struct{}) + for _, role := range roleNames { + orgID := role.OrganizationID + if orgID == uuid.Nil { + continue + } + if _, ok := seen[orgID]; ok { + continue + } + seen[orgID] = struct{}{} + if authorizer.Authorize(ctx, subject, policy.ActionCreate, + rbac.ResourceWorkspace.InOrg(orgID).WithOwner(subject.ID)) == nil { + return true, nil + } + } + return false, nil +} + +// authorizationSignature returns a canonical key for the user's role and +// group sets. Two users with equal signatures are interchangeable for +// workspace-create evaluation. +func authorizationSignature(row database.GetActiveUsersAuthorizationRolesRow) string { + roles := make([]string, len(row.Roles)) + copy(roles, row.Roles) + sort.Strings(roles) + groups := make([]string, len(row.Groups)) + copy(groups, row.Groups) + sort.Strings(groups) + return strings.Join(roles, "\x00") + "\x01" + strings.Join(groups, "\x00") +} diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go new file mode 100644 index 00000000000..5d87ab28d2c --- /dev/null +++ b/enterprise/coderd/license/usercount_test.go @@ -0,0 +1,344 @@ +package license_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" +) + +// TestCountWorkspaceCapableUsers verifies permission-based license seat +// counting: only users the RBAC engine authorizes to create workspaces +// consume seats, so members without workspace-create ("gateway accounts") +// are excluded. +// +// The subtests toggle the global builtin roles via ReloadBuiltinRoles, so +// they must run serially. +// +//nolint:tparallel,paralleltest +func TestCountWorkspaceCapableUsers(t *testing.T) { + ctx := context.Background() + authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + + activeUser := func(t *testing.T, db database.Store, seed database.User) database.User { + seed.Status = database.UserStatusActive + return dbgen.User(t, db, seed) + } + member := func(t *testing.T, db database.Store, orgID uuid.UUID, user database.User, roles ...string) { + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgID, + UserID: user.ID, + Roles: roles, + }) + } + emptyDefaultRoles := func(t *testing.T, db database.Store, org database.Organization) { + _, err := db.UpdateOrganization(ctx, database.UpdateOrganizationParams{ + ID: org.ID, + UpdatedAt: dbtime.Now(), + Name: org.Name, + DisplayName: org.DisplayName, + Description: org.Description, + Icon: org.Icon, + DefaultOrgMemberRoles: []string{}, + }) + require.NoError(t, err) + } + + t.Run("ElevationBundledParity", func(t *testing.T) { + // MinimumImplicitMember off (default): organization-member bundles + // the workspace-ops elevation, so every active org member counts + // and the permission-based count matches the legacy count except + // for zero-org plain members. + rbac.ReloadBuiltinRoles(nil) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + plainMember := activeUser(t, db, database.User{}) + member(t, db, org.ID, plainMember) + + orgAdmin := activeUser(t, db, database.User{}) + member(t, db, org.ID, orgAdmin, rbac.RoleOrgAdmin()) + + owner := activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}}) + member(t, db, org.ID, owner) + + // Counts under legacy, not under permission-based: no org, no + // workspace-create anywhere. + activeUser(t, db, database.User{}) + + // Counts under both: the owner site role grants workspace-create + // in any organization, independent of membership. + activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}}) + + // Never counted: not active. + suspended := dbgen.User(t, db, database.User{Status: database.UserStatusSuspended}) + member(t, db, org.ID, suspended) + dormant := dbgen.User(t, db, database.User{Status: database.UserStatusDormant}) + member(t, db, org.ID, dormant) + + // Never counted: service accounts are excluded from seat counts. + sa := activeUser(t, db, database.User{IsServiceAccount: true}) + member(t, db, org.ID, sa) + + legacy, err := db.GetActiveUserCount(ctx, false) + require.NoError(t, err) + require.Equal(t, int64(5), legacy) + + count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + require.NoError(t, err) + require.Equal(t, int64(4), count, "zero-org plain member must not count") + }) + + t.Run("MinimumImplicitMember", func(t *testing.T) { + // MinimumImplicitMember on: organization-member carries only the + // floor. Workspace-create flows exclusively through the + // organization-workspace-access role, granted explicitly or via + // default_org_member_roles. + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + emptyDefaultRoles(t, db, org) + + // Gateway account: floor only, no workspace-create. Not counted. + gateway := activeUser(t, db, database.User{}) + member(t, db, org.ID, gateway) + + // Explicit organization-workspace-access grant. Counted. + wsUser := activeUser(t, db, database.User{}) + member(t, db, org.ID, wsUser, rbac.RoleOrgWorkspaceAccess()) + + // The creation ban negates workspace-create even when the + // workspace-access role is present. Not counted. + banned := activeUser(t, db, database.User{}) + member(t, db, org.ID, banned, rbac.RoleOrgWorkspaceAccess(), rbac.RoleOrgWorkspaceCreationBan()) + + // Org admins retain workspace-create. Counted. + orgAdmin := activeUser(t, db, database.User{}) + member(t, db, org.ID, orgAdmin, rbac.RoleOrgAdmin()) + + // Owners retain workspace-create. Counted. + owner := activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}}) + member(t, db, org.ID, owner) + + // Members of an org that keeps organization-workspace-access in + // default_org_member_roles inherit workspace-create. Counted. + defaultOrg := dbgen.Organization(t, db, database.Organization{}) + defaultMember := activeUser(t, db, database.User{}) + member(t, db, defaultOrg.ID, defaultMember) + + count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + require.NoError(t, err) + require.Equal(t, int64(4), count) + }) + + t.Run("CustomOrgRole", func(t *testing.T) { + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + emptyDefaultRoles(t, db, org) + + creatorRole, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "workspace-creator", + DisplayName: "Workspace Creator", + OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, + OrgPermissions: []database.CustomRolePermission{{ + ResourceType: rbac.ResourceWorkspace.Type, + Action: policy.ActionCreate, + }}, + }) + require.NoError(t, err) + + auditRole, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "org-reader", + DisplayName: "Org Reader", + OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, + OrgPermissions: []database.CustomRolePermission{{ + ResourceType: rbac.ResourceOrganization.Type, + Action: policy.ActionRead, + }}, + }) + require.NoError(t, err) + + // Custom org role with workspace-create. Counted. + creator := activeUser(t, db, database.User{}) + member(t, db, org.ID, creator, creatorRole.Name) + + // Custom org role without workspace-create. Not counted. + reader := activeUser(t, db, database.User{}) + member(t, db, org.ID, reader, auditRole.Name) + + count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + require.NoError(t, err) + require.Equal(t, int64(1), count) + }) + + t.Run("EntitlementsAddonGate", func(t *testing.T) { + // Permission-based counting is gated on both the experiment and a + // valid license carrying the AI Governance addon. Without either, + // the legacy active user count applies. + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + emptyDefaultRoles(t, db, org) + + gateway := activeUser(t, db, database.User{}) + member(t, db, org.ID, gateway) + wsUser := activeUser(t, db, database.User{}) + member(t, db, org.ID, wsUser, rbac.RoleOrgWorkspaceAccess()) + + enablements := map[codersdk.FeatureName]bool{} + experimentOn := codersdk.Experiments{codersdk.ExperimentPermissionBasedLicensing} + + // No license: legacy count, even with the experiment on. + entitlements, err := license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + require.NoError(t, err) + require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + + // License without the AI Governance addon: still the legacy count. + _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }), + Exp: dbtime.Now().Add(time.Hour), + }) + require.NoError(t, err) + entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + require.NoError(t, err) + require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + + // License with the AI Governance addon: only the workspace-capable + // user counts. + _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, *(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).AIGovernanceAddon(10)), + Exp: dbtime.Now().Add(time.Hour), + }) + require.NoError(t, err) + entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + require.NoError(t, err) + require.Empty(t, entitlements.Errors) + require.Equal(t, int64(1), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + + // Addon present but experiment off: legacy count. + entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, nil) + require.NoError(t, err) + require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + + // Addon present, experiment on, but no authorizer: fall back to the + // legacy count instead of failing. + entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, nil, experimentOn) + require.NoError(t, err) + require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + }) + + t.Run("LicensesEntitlementsCountFn", func(t *testing.T) { + // Exercises LicensesEntitlements directly: the count function is + // only invoked when a valid license carries the addon, grace + // period licenses still gate the count, and count errors fall + // back to the legacy count with a recorded error. + now := time.Now() + enablements := map[codersdk.FeatureName]bool{} + + dbLicense := func(opts coderdenttest.LicenseOptions) database.License { + return database.License{ + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, opts), + Exp: now.Add(time.Hour * 24 * 60), + } + } + addonLicense := func() database.License { + return dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).Valid(now).AIGovernanceAddon(10)) + } + + t.Run("NoAddonFnNotCalled", func(t *testing.T) { + licenses := []database.License{dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).Valid(now))} + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + t.Fatal("count fn must not be called without the addon") + return 0, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + }) + + t.Run("AddonUsesFn", func(t *testing.T) { + entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 3, nil + }, + }) + require.NoError(t, err) + require.Empty(t, entitlements.Errors) + require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + }) + + t.Run("GracePeriodAddonUsesFn", func(t *testing.T) { + // A license in its grace period still includes the addon, so + // counting must not revert until the license hard-expires. + licenses := []database.License{dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).GracePeriod(now).AIGovernanceAddon(10))} + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 3, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + }) + + t.Run("FnErrorFallsBack", func(t *testing.T) { + entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 0, xerrors.New("boom") + }, + }) + require.NoError(t, err) + require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Len(t, entitlements.Errors, 1) + require.Contains(t, entitlements.Errors[0], "Error counting workspace-capable users") + }) + + t.Run("ContextCanceledBails", func(t *testing.T) { + _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 0, context.Canceled + }, + }) + require.ErrorIs(t, err, context.Canceled) + }) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3d89b954e2c..88c48db79d3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4841,6 +4841,7 @@ export type Experiment = | "nats_pubsub" | "notifications" | "oauth2" + | "permission-based-licensing" | "workspace-build-updates" | "workspace-usage"; @@ -4855,6 +4856,7 @@ export const Experiments: Experiment[] = [ "nats_pubsub", "notifications", "oauth2", + "permission-based-licensing", "workspace-build-updates", "workspace-usage", ]; From 5965e69dce7b6c4bf41481911cec1d4e0b3196a6 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Jul 2026 16:55:16 +0000 Subject: [PATCH 02/41] fix(enterprise/coderd/license): propagate workspace-capable count errors 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. --- enterprise/coderd/license/license.go | 17 ++++++----------- enterprise/coderd/license/usercount_test.go | 14 ++++++++------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index cd937a0480f..37d299a3197 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -427,19 +427,14 @@ func LicensesEntitlements( // feature value and the over-limit warnings below observe it. if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) - if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { - // If the context is canceled, we want to bail the entire - // LicensesEntitlements call. - return entitlements, xerrors.Errorf("count workspace capable users: %w", err) - } if err != nil { - // Fall back to the legacy active user count rather than failing - // the entitlements computation. - entitlements.Errors = append(entitlements.Errors, - fmt.Sprintf("Error counting workspace-capable users: %s", err.Error())) - } else { - featureArguments.ActiveUserCount = capableCount + // A failed seat count aborts the entitlements computation, + // matching the legacy active-user-count error semantics. The + // caller keeps the previous entitlements, so a failure yields a + // stale count rather than a silently different one. + return entitlements, xerrors.Errorf("count workspace capable users: %w", err) } + featureArguments.ActiveUserCount = capableCount } // Now the license specific warnings and errors are added to the entitlements. diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 5d87ab28d2c..624185821cd 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -318,17 +318,19 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) }) - t.Run("FnErrorFallsBack", func(t *testing.T) { - entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + t.Run("FnErrorPropagates", func(t *testing.T) { + // A failed capable count aborts the computation, matching the + // legacy active-user-count error semantics; the caller keeps + // the previous entitlements rather than seeing a silently + // different count. + _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 0, xerrors.New("boom") }, }) - require.NoError(t, err) - require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) - require.Len(t, entitlements.Errors, 1) - require.Contains(t, entitlements.Errors[0], "Error counting workspace-capable users") + require.ErrorContains(t, err, "count workspace capable users") + require.ErrorContains(t, err, "boom") }) t.Run("ContextCanceledBails", func(t *testing.T) { From 6c41d1938be37231734c90939e7f6ef3fed29c66 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 15 Jul 2026 18:05:07 +0000 Subject: [PATCH 03/41] perf(enterprise/coderd/license): dedupe seat counting on roles only 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. --- coderd/database/querier.go | 10 +- coderd/database/queries.sql.go | 30 ++---- coderd/database/queries/users.sql | 23 ++--- enterprise/coderd/license/usercount.go | 36 +++---- .../coderd/license/usercount_internal_test.go | 93 +++++++++++++++++++ 5 files changed, 136 insertions(+), 56 deletions(-) create mode 100644 enterprise/coderd/license/usercount_internal_test.go diff --git a/coderd/database/querier.go b/coderd/database/querier.go index fb0e8ab0398..860b3c838f7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -342,10 +342,12 @@ type sqlcQuerier interface { GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) // Returns the authorization roles (site and org-scoped, including implied - // member roles and organization default roles) plus group memberships for - // every user eligible for license seat counting: active, not deleted, and - // neither a system user nor a service account. Used by permission-based - // license seat counting to evaluate workspace-create capability. + // member roles and organization default roles) for every user eligible for + // license seat counting: active, not deleted, and neither a system user nor + // a service account. Used by permission-based license seat counting to + // evaluate workspace-create capability. Group memberships are intentionally + // not returned: they only influence authorization through object ACL + // matching, and the seat-count evaluation uses objects without ACLs. GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) // For PG Coordinator HTMLDebug diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2bf11e7544f..7a32bb46a28 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30204,18 +30204,7 @@ SELECT WHERE user_id = users.id ) - ) :: text[] AS roles, - -- All groups the user is in. - ( - SELECT - array_agg( - group_members.group_id :: text - ) - FROM - group_members - WHERE - user_id = users.id - ) :: text[] AS groups + ) :: text[] AS roles FROM users WHERE @@ -30226,16 +30215,17 @@ WHERE ` type GetActiveUsersAuthorizationRolesRow struct { - ID uuid.UUID `db:"id" json:"id"` - Roles []string `db:"roles" json:"roles"` - Groups []string `db:"groups" json:"groups"` + ID uuid.UUID `db:"id" json:"id"` + Roles []string `db:"roles" json:"roles"` } // Returns the authorization roles (site and org-scoped, including implied -// member roles and organization default roles) plus group memberships for -// every user eligible for license seat counting: active, not deleted, and -// neither a system user nor a service account. Used by permission-based -// license seat counting to evaluate workspace-create capability. +// member roles and organization default roles) for every user eligible for +// license seat counting: active, not deleted, and neither a system user nor +// a service account. Used by permission-based license seat counting to +// evaluate workspace-create capability. Group memberships are intentionally +// not returned: they only influence authorization through object ACL +// matching, and the seat-count evaluation uses objects without ACLs. func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) { rows, err := q.db.QueryContext(ctx, getActiveUsersAuthorizationRoles) if err != nil { @@ -30245,7 +30235,7 @@ func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]Ge var items []GetActiveUsersAuthorizationRolesRow for rows.Next() { var i GetActiveUsersAuthorizationRolesRow - if err := rows.Scan(&i.ID, pq.Array(&i.Roles), pq.Array(&i.Groups)); err != nil { + if err := rows.Scan(&i.ID, pq.Array(&i.Roles)); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 711fb0e41bb..882b129cf11 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -655,10 +655,12 @@ WHERE -- name: GetActiveUsersAuthorizationRoles :many -- Returns the authorization roles (site and org-scoped, including implied --- member roles and organization default roles) plus group memberships for --- every user eligible for license seat counting: active, not deleted, and --- neither a system user nor a service account. Used by permission-based --- license seat counting to evaluate workspace-create capability. +-- member roles and organization default roles) for every user eligible for +-- license seat counting: active, not deleted, and neither a system user nor +-- a service account. Used by permission-based license seat counting to +-- evaluate workspace-create capability. Group memberships are intentionally +-- not returned: they only influence authorization through object ACL +-- matching, and the seat-count evaluation uses objects without ACLs. SELECT id, array_cat( @@ -687,18 +689,7 @@ SELECT WHERE user_id = users.id ) - ) :: text[] AS roles, - -- All groups the user is in. - ( - SELECT - array_agg( - group_members.group_id :: text - ) - FROM - group_members - WHERE - user_id = users.id - ) :: text[] AS groups + ) :: text[] AS roles FROM users WHERE diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index c2af3b55490..c23c7dea679 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -29,11 +29,15 @@ func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authoriz return 0, xerrors.Errorf("get active users authorization roles: %w", err) } - // Users with identical role and group sets always produce the same - // authorization outcome because the subject ID and the object owner - // are the same user in every check. Deduplicate on that signature so - // evaluation cost scales with the number of unique role sets, not - // the number of users. + // Users with identical role sets always produce the same authorization + // outcome: the subject ID and the object owner are the same user in + // every check, and the objects carry no ACLs, so group membership + // (which only influences authorization through object ACL matching) + // cannot change the result. Deduplicate on the role signature so + // evaluation cost scales with the number of unique role sets, not the + // number of users. TestWorkspaceCreateIgnoresGroups enforces the + // group-independence assumption; if it ever breaks, groups must be + // added back to the subject, the signature, and the query. capableBySignature := make(map[string]bool) var count int64 for _, row := range rows { @@ -70,11 +74,14 @@ func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac. } subject := rbac.Subject{ - Type: rbac.SubjectTypeUser, - ID: row.ID.String(), - Roles: roles, - Groups: row.Groups, - Scope: rbac.ScopeAll, + Type: rbac.SubjectTypeUser, + ID: row.ID.String(), + Roles: roles, + // Groups are deliberately omitted: they only influence + // authorization through object ACL matching, and the objects + // below carry no ACLs. Keeping them off the subject keeps the + // role-only dedupe signature honest. + Scope: rbac.ScopeAll, }.WithCachedASTValue() // Site-wide grants (e.g. the owner role) authorize workspace creation @@ -103,15 +110,12 @@ func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac. return false, nil } -// authorizationSignature returns a canonical key for the user's role and -// group sets. Two users with equal signatures are interchangeable for +// authorizationSignature returns a canonical key for the user's role set. +// Two users with equal signatures are interchangeable for // workspace-create evaluation. func authorizationSignature(row database.GetActiveUsersAuthorizationRolesRow) string { roles := make([]string, len(row.Roles)) copy(roles, row.Roles) sort.Strings(roles) - groups := make([]string, len(row.Groups)) - copy(groups, row.Groups) - sort.Strings(groups) - return strings.Join(roles, "\x00") + "\x01" + strings.Join(groups, "\x00") + return strings.Join(roles, "\x00") } diff --git a/enterprise/coderd/license/usercount_internal_test.go b/enterprise/coderd/license/usercount_internal_test.go new file mode 100644 index 00000000000..25a5257cd59 --- /dev/null +++ b/enterprise/coderd/license/usercount_internal_test.go @@ -0,0 +1,93 @@ +package license + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +// TestWorkspaceCreateIgnoresGroups reinforces the assumption that lets +// CountWorkspaceCapableUsers deduplicate on roles alone and omit groups +// from the evaluation subject: group membership must not change the +// workspace-create outcome for the objects canCreateWorkspace uses. +// Those objects carry no ACLs, and groups only influence authorization +// through object ACL matching, so a subject with groups and one without +// must authorize identically. +// +// If this test fails, the policy has become group-sensitive for +// ACL-less workspace objects. Groups must then be added back to the +// evaluation subject, the dedupe signature, and the +// GetActiveUsersAuthorizationRoles query, or the cached verdicts will +// be shared across users with different authorization outcomes. +func TestWorkspaceCreateIgnoresGroups(t *testing.T) { + t.Parallel() + + auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + orgID := uuid.New() + userID := uuid.New() + + roleSets := map[string][]rbac.RoleIdentifier{ + "member only": {rbac.RoleMember()}, + "org member floor": { + rbac.RoleMember(), + rbac.ScopedRoleOrgMember(orgID), + }, + "workspace access": { + rbac.RoleMember(), + rbac.ScopedRoleOrgMember(orgID), + rbac.ScopedRoleOrgWorkspaceAccess(orgID), + }, + "creation ban": { + rbac.RoleMember(), + rbac.ScopedRoleOrgMember(orgID), + rbac.ScopedRoleOrgWorkspaceAccess(orgID), + rbac.ScopedRoleOrgWorkspaceCreationBan(orgID), + }, + "org admin": { + rbac.RoleMember(), + rbac.ScopedRoleOrgAdmin(orgID), + }, + "owner": {rbac.RoleMember(), rbac.RoleOwner()}, + } + + // The org ID doubles as the Everyone group ID, making it the + // adversarial group membership for ACL-related rules. + groups := []string{orgID.String(), uuid.NewString(), uuid.NewString()} + + // The same object shapes canCreateWorkspace evaluates: no ACLs. + objects := map[string]rbac.Object{ + "any org": rbac.ResourceWorkspace.AnyOrganization().WithOwner(userID.String()), + "in org": rbac.ResourceWorkspace.InOrg(orgID).WithOwner(userID.String()), + } + + for name, roleNames := range roleSets { + t.Run(name, func(t *testing.T) { + t.Parallel() + + base := rbac.Subject{ + Type: rbac.SubjectTypeUser, + ID: userID.String(), + Roles: rbac.RoleIdentifiers(roleNames), + Scope: rbac.ScopeAll, + } + withGroups := base + withGroups.Groups = groups + + for objName, obj := range objects { + errWithout := auth.Authorize(context.Background(), base, policy.ActionCreate, obj) + errWith := auth.Authorize(context.Background(), withGroups, policy.ActionCreate, obj) + require.Equal(t, + errWithout == nil, errWith == nil, + "object %q: outcome must not depend on groups (without: %v, with: %v)", + objName, errWithout, errWith, + ) + } + }) + } +} From b92588882d2d376ee50d6f6edd7944021d9b9c15 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 15:43:40 +0000 Subject: [PATCH 04/41] fix(enterprise/coderd/license): tolerate malformed stored roles in seat 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. --- enterprise/coderd/license/usercount.go | 7 ++++++- enterprise/coderd/license/usercount_test.go | 22 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index c23c7dea679..8f63f4c0a57 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -64,7 +64,12 @@ func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authoriz func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { roleNames, err := row.RoleNames() if err != nil { - return false, xerrors.Errorf("expand role names: %w", err) + // A stored role string that fails to parse grants nothing: + // authorization fails closed on it, so this user cannot create a + // workspace. Treat the user as not capable rather than returning + // the error, which would fail the count for every user over one + // bad row. + return false, nil } //nolint:gocritic // Expanding custom roles requires system access. diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 624185821cd..e195d8afc50 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -192,6 +192,28 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(1), count) }) + t.Run("MalformedRoleNotCounted", func(t *testing.T) { + rbac.ReloadBuiltinRoles(nil) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + // Authorization fails closed on an unparseable stored role, so + // this user is not workspace-capable even though their org + // membership would otherwise qualify them. + corrupt := activeUser(t, db, database.User{RBACRoles: []string{"bad:role:extra"}}) + member(t, db, org.ID, corrupt) + + // The bad row must not fail the count for everyone else. + capable := activeUser(t, db, database.User{}) + member(t, db, org.ID, capable) + + count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + require.NoError(t, err) + require.Equal(t, int64(1), count) + }) + t.Run("EntitlementsAddonGate", func(t *testing.T) { // Permission-based counting is gated on both the experiment and a // valid license carrying the AI Governance addon. Without either, From ac69c952e8a450e80221bdb5b2a1f60e29f0b0cc Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 16:11:57 +0000 Subject: [PATCH 05/41] feat(enterprise/coderd/license): log a warning when a stored role fails 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. --- enterprise/coderd/coderd.go | 2 +- enterprise/coderd/license/license.go | 5 +- enterprise/coderd/license/license_test.go | 79 +++++++++++---------- enterprise/coderd/license/usercount.go | 15 ++-- enterprise/coderd/license/usercount_test.go | 19 ++--- 5 files changed, 66 insertions(+), 54 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 36598bf5a3d..857161b4edf 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -937,7 +937,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { } reloadedEntitlements, err := license.Entitlements( - ctx, api.Database, + ctx, api.Logger, api.Database, len(agedReplicas), len(api.ExternalAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{ codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureConnectionLog: api.ConnectionLogging, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 37d299a3197..aad36b4af84 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -13,6 +13,8 @@ import ( "github.com/golang-jwt/jwt/v4" "golang.org/x/xerrors" + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/rbac" @@ -27,6 +29,7 @@ import ( // 3. generate warnings related to usage func Entitlements( ctx context.Context, + logger slog.Logger, db database.Store, replicaCount int, externalAuthCount int, @@ -58,7 +61,7 @@ func Entitlements( var workspaceCapableUserCountFn WorkspaceCapableUserCountFn if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { - return CountWorkspaceCapableUsers(ctx, db, authorizer) + return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) } } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index f41c26c2dfd..78e755f3d27 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" ) func TestEntitlements(t *testing.T) { @@ -33,7 +34,7 @@ func TestEntitlements(t *testing.T) { t.Run("Defaults", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -45,7 +46,7 @@ func TestEntitlements(t *testing.T) { t.Run("Always return the current user count", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -58,7 +59,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -86,7 +87,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -110,7 +111,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -137,7 +138,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -166,7 +167,7 @@ func TestEntitlements(t *testing.T) { Exp: time.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -202,7 +203,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be generated. - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -230,7 +231,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be suppressed. - entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -261,7 +262,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Should generate a warning. - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -289,7 +290,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should still be generated. - entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -315,7 +316,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -344,7 +345,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -364,7 +365,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -434,7 +435,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -462,7 +463,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(60 * 24 * time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -485,7 +486,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -501,7 +502,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -549,7 +550,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, licenseOptions), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -600,7 +601,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -621,7 +622,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -654,7 +655,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -688,7 +689,7 @@ func TestEntitlements(t *testing.T) { ExpiresAt: dbtime.Now().Add(time.Hour), }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -714,7 +715,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleReplicasNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -732,7 +733,7 @@ func TestEntitlements(t *testing.T) { }, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, }, nil, nil) require.NoError(t, err) @@ -755,7 +756,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, }, nil, nil) require.NoError(t, err) @@ -767,7 +768,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleGitAuthNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -785,7 +786,7 @@ func TestEntitlements(t *testing.T) { }, }), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, }, nil, nil) require.NoError(t, err) @@ -808,7 +809,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, }, nil, nil) require.NoError(t, err) @@ -875,7 +876,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -993,7 +994,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1063,7 +1064,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1126,7 +1127,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1186,7 +1187,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2163,7 +2164,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2195,7 +2196,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2233,7 +2234,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2264,7 +2265,7 @@ func TestAIGovernanceAddon(t *testing.T) { Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2297,7 +2298,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 8f63f4c0a57..beb00e7ede9 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/rbac" @@ -22,7 +24,7 @@ import ( // accounts") do not consume license seats. System users and service // accounts are excluded by the underlying query, matching // GetActiveUserCount. -func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authorizer rbac.Authorizer) (int64, error) { +func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { //nolint:gocritic // Counting licensed seats is a system function. rows, err := db.GetActiveUsersAuthorizationRoles(dbauthz.AsSystemRestricted(ctx)) if err != nil { @@ -44,7 +46,7 @@ func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authoriz sig := authorizationSignature(row) capable, ok := capableBySignature[sig] if !ok { - capable, err = canCreateWorkspace(ctx, db, authorizer, row) + capable, err = canCreateWorkspace(ctx, logger, db, authorizer, row) if err != nil { return 0, xerrors.Errorf("evaluate workspace-create for user %s: %w", row.ID, err) } @@ -61,14 +63,19 @@ func CountWorkspaceCapableUsers(ctx context.Context, db database.Store, authoriz // to create a workspace they own, checked against every organization the // user is a member of plus the any-organization form that site-wide roles // satisfy regardless of org membership. -func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { +func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { roleNames, err := row.RoleNames() if err != nil { // A stored role string that fails to parse grants nothing: // authorization fails closed on it, so this user cannot create a // workspace. Treat the user as not capable rather than returning // the error, which would fail the count for every user over one - // bad row. + // bad row. Role-signature dedupe means this logs once per unique + // role set, not once per user sharing it. + logger.Warn(ctx, "user has an unparseable role, counting them as not workspace-capable for license seats", + slog.F("user_id", row.ID), + slog.Error(err), + ) return false, nil } diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index e195d8afc50..fa8891a13b0 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" ) // TestCountWorkspaceCapableUsers verifies permission-based license seat @@ -100,7 +101,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(5), legacy) - count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer) require.NoError(t, err) require.Equal(t, int64(4), count, "zero-org plain member must not count") }) @@ -144,7 +145,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { defaultMember := activeUser(t, db, database.User{}) member(t, db, defaultOrg.ID, defaultMember) - count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer) require.NoError(t, err) require.Equal(t, int64(4), count) }) @@ -187,7 +188,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { reader := activeUser(t, db, database.User{}) member(t, db, org.ID, reader, auditRole.Name) - count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer) require.NoError(t, err) require.Equal(t, int64(1), count) }) @@ -209,7 +210,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { capable := activeUser(t, db, database.User{}) member(t, db, org.ID, capable) - count, err := license.CountWorkspaceCapableUsers(ctx, db, authorizer) + count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer) require.NoError(t, err) require.Equal(t, int64(1), count) }) @@ -234,7 +235,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { experimentOn := codersdk.Experiments{codersdk.ExperimentPermissionBasedLicensing} // No license: legacy count, even with the experiment on. - entitlements, err := license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + entitlements, err := license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) require.NoError(t, err) require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) @@ -246,7 +247,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { Exp: dbtime.Now().Add(time.Hour), }) require.NoError(t, err) - entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) require.NoError(t, err) require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) @@ -259,19 +260,19 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { Exp: dbtime.Now().Add(time.Hour), }) require.NoError(t, err) - entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) + entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) require.NoError(t, err) require.Empty(t, entitlements.Errors) require.Equal(t, int64(1), *entitlements.Features[codersdk.FeatureUserLimit].Actual) // Addon present but experiment off: legacy count. - entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, authorizer, nil) + entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, nil) require.NoError(t, err) require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) // Addon present, experiment on, but no authorizer: fall back to the // legacy count instead of failing. - entitlements, err = license.Entitlements(ctx, db, 1, 1, coderdenttest.Keys, enablements, nil, experimentOn) + entitlements, err = license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, experimentOn) require.NoError(t, err) require.Equal(t, int64(2), *entitlements.Features[codersdk.FeatureUserLimit].Actual) }) From 211ae15716703b67c2374b33c664d69b55349b1b Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 16:27:52 +0000 Subject: [PATCH 06/41] fix(enterprise/coderd/license): name workspace-capable users in user_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. --- enterprise/coderd/license/license.go | 17 +++++++++++---- enterprise/coderd/license/usercount_test.go | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index aad36b4af84..8102f74ecc0 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -428,6 +428,7 @@ func LicensesEntitlements( // The resolved count overwrites featureArguments.ActiveUserCount, // which the user_limit feature's Actual pointer aliases, so both the // feature value and the over-limit warnings below observe it. + permissionBasedUserCount := false if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) if err != nil { @@ -438,6 +439,7 @@ func LicensesEntitlements( return entitlements, xerrors.Errorf("count workspace capable users: %w", err) } featureArguments.ActiveUserCount = capableCount + permissionBasedUserCount = true } // Now the license specific warnings and errors are added to the entitlements. @@ -534,14 +536,21 @@ func LicensesEntitlements( if entitlements.HasLicense { userLimit := entitlements.Features[codersdk.FeatureUserLimit] + // With permission-based counting, ActiveUserCount holds only + // workspace-capable users, not all active users; the warning must + // name what was counted. + userNoun := "active users" + if permissionBasedUserCount { + userNoun = "workspace-capable users" + } if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( - "Your deployment has %d active users but is only licensed for %d.", - featureArguments.ActiveUserCount, *userLimit.Limit)) + "Your deployment has %d %s but is only licensed for %d.", + featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) } else if userLimit.Limit != nil && userLimit.Entitlement == codersdk.EntitlementGracePeriod { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( - "Your deployment has %d active users but the license with the limit %d is expired.", - featureArguments.ActiveUserCount, *userLimit.Limit)) + "Your deployment has %d %s but the license with the limit %d is expired.", + featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) } if featureArguments.ActiveAISeatCount > 0 { actual := featureArguments.ActiveAISeatCount diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index fa8891a13b0..1d0dd2797ef 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -323,6 +323,27 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.NoError(t, err) require.Empty(t, entitlements.Errors) require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + // Under the limit: no user-limit warning, even though the + // legacy active user count would also have been under it. + for _, warning := range entitlements.Warnings { + require.NotContains(t, warning, "users but") + } + }) + + t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) { + // The over-limit warning must report the workspace-capable + // count it was compared against, and say so, rather than + // claiming that many "active users" exist. + entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 150, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Contains(t, entitlements.Warnings, + "Your deployment has 150 workspace-capable users but is only licensed for 100.") }) t.Run("GracePeriodAddonUsesFn", func(t *testing.T) { @@ -339,6 +360,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) require.NoError(t, err) require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Contains(t, entitlements.Warnings, + "Your deployment has 3 workspace-capable users but the license with the limit 100 is expired.") }) t.Run("FnErrorPropagates", func(t *testing.T) { From d537e1662bf75a5eef1410e3581c418ca573eb8c Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 16:29:39 +0000 Subject: [PATCH 07/41] perf(enterprise/coderd/license): share the custom role cache across seat-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. --- enterprise/coderd/license/usercount.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index beb00e7ede9..9856aeb46da 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -25,6 +25,11 @@ import ( // accounts are excluded by the underlying query, matching // GetActiveUserCount. func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { + // Custom roles resolved by rolestore.Expand are cached on the context, + // so each distinct custom role is fetched from the database once per + // count rather than once per unique role set referencing it. + ctx = rolestore.CustomRoleCacheContext(ctx) + //nolint:gocritic // Counting licensed seats is a system function. rows, err := db.GetActiveUsersAuthorizationRoles(dbauthz.AsSystemRestricted(ctx)) if err != nil { From ec0135b38341ca637ac413597f01866e830845fe Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 16:46:28 +0000 Subject: [PATCH 08/41] perf(coderd/database): aggregate seat-counting roles once instead of 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. --- coderd/database/queries.sql.go | 55 +++++++++++++++++-------------- coderd/database/queries/users.sql | 55 +++++++++++++++++-------------- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7a32bb46a28..428d060bd88 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30176,37 +30176,44 @@ func (q *sqlQuerier) GetActiveUserCount(ctx context.Context, includeSystem bool) } const getActiveUsersAuthorizationRoles = `-- name: GetActiveUsersAuthorizationRoles :many +WITH org_roles AS ( + -- Aggregated once over all memberships and hash-joined to users below; + -- a correlated per-user subquery would re-execute per user row. + SELECT + organization_members.user_id, + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_role || ':' || organization_members.organization_id::text) AS roles + FROM + organization_members + JOIN organizations ON organizations.id = organization_members.organization_id, + -- All org members get an implied organization-member role for + -- their orgs. Memberships of service accounts are aggregated here + -- too, but their rows never survive the join against the outer + -- WHERE, so the organization-service-account case does not apply. + -- + -- organizations.default_org_member_roles is unioned in so changes + -- to org defaults propagate on the next entitlement refresh. + unnest( + array_cat( + array_append(organization_members.roles, 'organization-member'), + organizations.default_org_member_roles + ) + ) AS org_role + GROUP BY + organization_members.user_id +) SELECT - id, + users.id, array_cat( -- All users are members array_append(users.rbac_roles, 'member'), - ( - SELECT - -- The roles are returned as a flat array, org scoped and site side. - -- Concatenating the organization id scopes the organization roles. - array_agg(org_roles || ':' || organization_members.organization_id::text) - FROM - organization_members - JOIN organizations ON organizations.id = organization_members.organization_id, - -- All org members get an implied organization-member role for - -- their orgs. Service accounts are excluded by the outer WHERE, - -- so the organization-service-account case does not apply here. - -- - -- organizations.default_org_member_roles is unioned in so changes - -- to org defaults propagate on the next entitlement refresh. - unnest( - array_cat( - array_append(roles, 'organization-member'), - organizations.default_org_member_roles - ) - ) AS org_roles - WHERE - user_id = users.id - ) + -- Users with no org memberships have no org_roles row. + coalesce(org_roles.roles, ARRAY[]::text[]) ) :: text[] AS roles FROM users + LEFT JOIN org_roles ON org_roles.user_id = users.id WHERE users.status = 'active'::user_status AND users.deleted = false diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 882b129cf11..7f9e9f12b38 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -661,37 +661,44 @@ WHERE -- evaluate workspace-create capability. Group memberships are intentionally -- not returned: they only influence authorization through object ACL -- matching, and the seat-count evaluation uses objects without ACLs. +WITH org_roles AS ( + -- Aggregated once over all memberships and hash-joined to users below; + -- a correlated per-user subquery would re-execute per user row. + SELECT + organization_members.user_id, + -- The roles are returned as a flat array, org scoped and site side. + -- Concatenating the organization id scopes the organization roles. + array_agg(org_role || ':' || organization_members.organization_id::text) AS roles + FROM + organization_members + JOIN organizations ON organizations.id = organization_members.organization_id, + -- All org members get an implied organization-member role for + -- their orgs. Memberships of service accounts are aggregated here + -- too, but their rows never survive the join against the outer + -- WHERE, so the organization-service-account case does not apply. + -- + -- organizations.default_org_member_roles is unioned in so changes + -- to org defaults propagate on the next entitlement refresh. + unnest( + array_cat( + array_append(organization_members.roles, 'organization-member'), + organizations.default_org_member_roles + ) + ) AS org_role + GROUP BY + organization_members.user_id +) SELECT - id, + users.id, array_cat( -- All users are members array_append(users.rbac_roles, 'member'), - ( - SELECT - -- The roles are returned as a flat array, org scoped and site side. - -- Concatenating the organization id scopes the organization roles. - array_agg(org_roles || ':' || organization_members.organization_id::text) - FROM - organization_members - JOIN organizations ON organizations.id = organization_members.organization_id, - -- All org members get an implied organization-member role for - -- their orgs. Service accounts are excluded by the outer WHERE, - -- so the organization-service-account case does not apply here. - -- - -- organizations.default_org_member_roles is unioned in so changes - -- to org defaults propagate on the next entitlement refresh. - unnest( - array_cat( - array_append(roles, 'organization-member'), - organizations.default_org_member_roles - ) - ) AS org_roles - WHERE - user_id = users.id - ) + -- Users with no org memberships have no org_roles row. + coalesce(org_roles.roles, ARRAY[]::text[]) ) :: text[] AS roles FROM users + LEFT JOIN org_roles ON org_roles.user_id = users.id WHERE users.status = 'active'::user_status AND users.deleted = false From 6be238b0dc49af4150bc40e413dbb4e14f4c13fd Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 16:49:55 +0000 Subject: [PATCH 09/41] chore(coderd/database): drop redundant comment from seat-counting query --- coderd/database/queries.sql.go | 2 -- coderd/database/queries/users.sql | 2 -- 2 files changed, 4 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 428d060bd88..f639bd66c02 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30177,8 +30177,6 @@ func (q *sqlQuerier) GetActiveUserCount(ctx context.Context, includeSystem bool) const getActiveUsersAuthorizationRoles = `-- name: GetActiveUsersAuthorizationRoles :many WITH org_roles AS ( - -- Aggregated once over all memberships and hash-joined to users below; - -- a correlated per-user subquery would re-execute per user row. SELECT organization_members.user_id, -- The roles are returned as a flat array, org scoped and site side. diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 7f9e9f12b38..5d3e4d7e41f 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -662,8 +662,6 @@ WHERE -- not returned: they only influence authorization through object ACL -- matching, and the seat-count evaluation uses objects without ACLs. WITH org_roles AS ( - -- Aggregated once over all memberships and hash-joined to users below; - -- a correlated per-user subquery would re-execute per user row. SELECT organization_members.user_id, -- The roles are returned as a flat array, org scoped and site side. From faca8230d70a49cb46448630485f23a13aeb0107 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 17:46:12 +0000 Subject: [PATCH 10/41] perf(enterprise/coderd/license): prefetch all custom roles for seat counting 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. --- coderd/rbac/rolestore/rolestore.go | 28 ++++++++++++++++++++ coderd/rbac/rolestore/rolestore_test.go | 34 +++++++++++++++++++++++++ enterprise/coderd/license/usercount.go | 12 ++++++--- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 9f95c1870a8..233df7ae527 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -32,6 +32,34 @@ func CustomRoleCacheContext(ctx context.Context) context.Context { return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]()) } +// PrefetchCustomRoles fetches every custom role in a single query and +// stores them in the returned context's role cache, so Expand calls on +// that context resolve custom roles without further database lookups. +// Roles deleted after the prefetch are still absent from the cache and +// fall back to an individual lookup on Expand. +func PrefetchCustomRoles(ctx context.Context, db database.Store) (context.Context, error) { + ctx = CustomRoleCacheContext(ctx) + cache := roleCache(ctx) + + dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{ + LookupRoles: nil, + ExcludeOrgRoles: false, + OrganizationID: uuid.Nil, + IncludeSystemRoles: true, + }) + if err != nil { + return ctx, xerrors.Errorf("fetch custom roles: %w", err) + } + for _, dbrole := range dbroles { + converted, err := ConvertDBRole(dbrole) + if err != nil { + return ctx, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err) + } + cache.Store(dbrole.RoleIdentifier().String(), converted) + } + return ctx, nil +} + func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role]) if !ok { diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 80b6fb40f4c..46b394e235a 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -1,15 +1,18 @@ package rolestore_test import ( + "context" "database/sql" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/rolestore" @@ -42,6 +45,37 @@ func TestExpandCustomRoleRoles(t *testing.T) { require.Len(t, roles, 1, "role found") } +func TestPrefetchCustomRoles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + + orgID := uuid.New() + prefetched := database.CustomRole{ + Name: "prefetched", + DisplayName: "Prefetched", + OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}, + } + // The mock permits exactly one CustomRoles call: the unfiltered + // prefetch. A cache miss in Expand below would fail the test with an + // unexpected second call. + mDB.EXPECT().CustomRoles(gomock.Any(), database.CustomRolesParams{ + LookupRoles: nil, + ExcludeOrgRoles: false, + OrganizationID: uuid.Nil, + IncludeSystemRoles: true, + }).Times(1).Return([]database.CustomRole{prefetched}, nil) + + ctx, err := rolestore.PrefetchCustomRoles(context.Background(), mDB) + require.NoError(t, err) + + roles, err := rolestore.Expand(ctx, mDB, []rbac.RoleIdentifier{{Name: "prefetched", OrganizationID: orgID}}) + require.NoError(t, err) + require.Len(t, roles, 1) + require.Equal(t, "prefetched", roles[0].Identifier.Name) +} + func TestReconcileSystemRole(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 9856aeb46da..50b87933e21 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -25,10 +25,14 @@ import ( // accounts are excluded by the underlying query, matching // GetActiveUserCount. func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { - // Custom roles resolved by rolestore.Expand are cached on the context, - // so each distinct custom role is fetched from the database once per - // count rather than once per unique role set referencing it. - ctx = rolestore.CustomRoleCacheContext(ctx) + // All custom roles are prefetched into the context's role cache in a + // single query; role expansion below then resolves both builtin and + // custom roles without per-role-set database lookups. + //nolint:gocritic // Counting licensed seats is a system function. + ctx, err := rolestore.PrefetchCustomRoles(dbauthz.AsSystemRestricted(ctx), db) + if err != nil { + return 0, xerrors.Errorf("prefetch custom roles: %w", err) + } //nolint:gocritic // Counting licensed seats is a system function. rows, err := db.GetActiveUsersAuthorizationRoles(dbauthz.AsSystemRestricted(ctx)) From 2e22100f404af025528c45c7861dee3cda3a9e44 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 18:28:48 +0000 Subject: [PATCH 11/41] chore(enterprise/coderd/license): trim seat-consumption phrasing from count doc comment --- enterprise/coderd/license/usercount.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 50b87933e21..60f4fac1f55 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -20,10 +20,8 @@ import ( // CountWorkspaceCapableUsers returns the number of active users the RBAC // engine authorizes to create a workspace, either in one of the // organizations they belong to or in any organization via a site-wide -// role such as owner. Users without workspace-create capability ("gateway -// accounts") do not consume license seats. System users and service -// accounts are excluded by the underlying query, matching -// GetActiveUserCount. +// role such as owner. System users and service accounts are excluded by +// the underlying query, matching GetActiveUserCount. func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { // All custom roles are prefetched into the context's role cache in a // single query; role expansion below then resolves both builtin and From de422d68a379ab707b74a53712e4db473c5e3a73 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 19:08:50 +0000 Subject: [PATCH 12/41] feat(enterprise/coderd/license): log the workspace-capable seat count 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. --- enterprise/coderd/license/usercount.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 60f4fac1f55..964ea549976 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -4,6 +4,7 @@ import ( "context" "sort" "strings" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -23,6 +24,8 @@ import ( // role such as owner. System users and service accounts are excluded by // the underlying query, matching GetActiveUserCount. func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { + start := time.Now() + // All custom roles are prefetched into the context's role cache in a // single query; role expansion below then resolves both builtin and // custom roles without per-role-set database lookups. @@ -63,6 +66,17 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data count++ } } + + // This line doubles as the counting-mode signal: it is emitted only + // when permission-based seat counting ran, and it carries both the + // counted seats and the total eligible active users so the two can be + // compared when diagnosing seat disputes. + logger.Info(ctx, "counted workspace-capable users for license seats", + slog.F("workspace_capable_users", count), + slog.F("active_users", len(rows)), + slog.F("unique_role_sets", len(capableBySignature)), + slog.F("elapsed", time.Since(start)), + ) return count, nil } From bcef91734bc63d95ce951ba048c705593ae6981a Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 19:16:10 +0000 Subject: [PATCH 13/41] test(enterprise/coderd/license): cover multi-org split capability in 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. --- enterprise/coderd/license/usercount_test.go | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 1d0dd2797ef..2901af1fed7 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -150,6 +150,39 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(4), count) }) + t.Run("MultiOrgSplitCapability", func(t *testing.T) { + // Users whose capability differs between their organizations: + // workspace-create in any one org is sufficient to be counted. + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) + t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + db, _ := dbtestutil.NewDB(t) + orgA := dbgen.Organization(t, db, database.Organization{}) + orgB := dbgen.Organization(t, db, database.Organization{}) + emptyDefaultRoles(t, db, orgA) + emptyDefaultRoles(t, db, orgB) + + // Gateway in org A, workspace-create in org B. Counted. + split := activeUser(t, db, database.User{}) + member(t, db, orgA.ID, split) + member(t, db, orgB.ID, split, rbac.RoleOrgWorkspaceAccess()) + + // The creation ban is scoped to org A and must not negate the + // org B grant. Counted. + bannedSplit := activeUser(t, db, database.User{}) + member(t, db, orgA.ID, bannedSplit, rbac.RoleOrgWorkspaceAccess(), rbac.RoleOrgWorkspaceCreationBan()) + member(t, db, orgB.ID, bannedSplit, rbac.RoleOrgWorkspaceAccess()) + + // Gateway in both orgs. Not counted. + gateway := activeUser(t, db, database.User{}) + member(t, db, orgA.ID, gateway) + member(t, db, orgB.ID, gateway) + + count, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), db, authorizer) + require.NoError(t, err) + require.Equal(t, int64(2), count) + }) + t.Run("CustomOrgRole", func(t *testing.T) { rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) From 5a9ec68d068797b4b2ab9ca2c029b6d01dce5459 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 19:31:05 +0000 Subject: [PATCH 14/41] chore(enterprise/coderd/license): address review nits around gating and 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. --- coderd/database/dbauthz/dbauthz_test.go | 2 +- codersdk/deployment.go | 3 +++ enterprise/coderd/license/license.go | 15 +++++++++------ enterprise/coderd/license/usercount_test.go | 20 ++++++++++++++++++++ 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a8f54c8005f..a212ae933d6 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5040,7 +5040,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { })) s.Run("GetActiveUsersAuthorizationRoles", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return([]database.GetActiveUsersAuthorizationRolesRow{}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) + check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.GetActiveUsersAuthorizationRolesRow{}) })) s.Run("GetAuthorizationUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ce5e594bc9c..24db9b11713 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5324,6 +5324,9 @@ var ExperimentsKnown = Experiments{ // not be included here and will be essentially hidden. var ExperimentsSafe = Experiments{ ExperimentMinimumImplicitMember, + // ExperimentPermissionBasedLicensing is deliberately excluded: it + // changes how license seats are counted, so enabling it must be an + // explicit opt-in rather than a side effect of --experiments='*'. } // Experiments is a list of experiments. diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 8102f74ecc0..24f43ca8f6d 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -426,16 +426,19 @@ func LicensesEntitlements( // Governance addon, only workspace-capable users consume user_limit // seats; gateway accounts (users without workspace-create) are free. // The resolved count overwrites featureArguments.ActiveUserCount, - // which the user_limit feature's Actual pointer aliases, so both the - // feature value and the over-limit warnings below observe it. + // which the user_limit feature's Actual pointer aliases: Feature + // values copy the pointer, not the int64, so every copy of the + // feature observes the write, as do the over-limit warnings below. + // Replacing Actual with a fresh allocation would break this. permissionBasedUserCount := false if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) if err != nil { - // A failed seat count aborts the entitlements computation, - // matching the legacy active-user-count error semantics. The - // caller keeps the previous entitlements, so a failure yields a - // stale count rather than a silently different one. + // A failed seat count is deliberately a hard failure rather + // than a recorded entitlement error: continuing with + // ActiveUserCount would silently change what user_limit + // measures. The caller keeps the previous entitlements, so a + // failure yields a stale count rather than a different one. return entitlements, xerrors.Errorf("count workspace capable users: %w", err) } featureArguments.ActiveUserCount = capableCount diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 2901af1fed7..26e18adef79 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -346,6 +346,26 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) }) + t.Run("AddonMissingDependenciesIgnored", func(t *testing.T) { + // A license carrying the addon without its required features + // records a validation error and the addon is skipped, so + // permission-based counting must not activate. + opts := (&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).Valid(now) + opts.Addons = append(opts.Addons, codersdk.AddonAIGovernance) + entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{dbLicense(*opts)}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + t.Fatal("count fn must not be called when addon dependencies are unmet") + return 0, nil + }, + }) + require.NoError(t, err) + require.NotEmpty(t, entitlements.Errors) + require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + }) + t.Run("AddonUsesFn", func(t *testing.T) { entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, From bab7391887d8ded96e2db4097d3c36cb406cbbce Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 19:50:33 +0000 Subject: [PATCH 15/41] fix(enterprise/coderd/license): bind permission-based counting to the 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. --- enterprise/coderd/license/license.go | 36 +++++++++++++++++++++ enterprise/coderd/license/usercount_test.go | 33 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 24f43ca8f6d..95f28217fc3 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -163,6 +163,13 @@ func LicensesEntitlements( // its dependencies satisfied. This gates permission-based seat // counting below. hasAIGovernanceAddon := false + // aiGovernanceAddonEntitled is true when at least one addon-carrying + // license is fully valid rather than in its grace period. + aiGovernanceAddonEntitled := false + // aiGovernanceAddonUserLimit is the highest user_limit claimed by an + // addon-carrying license. Permission-based counting is enforced + // against this limit rather than limits from non-addon licenses. + var aiGovernanceAddonUserLimit int64 // Default all entitlements to be disabled. entitlements := codersdk.Entitlements{ @@ -407,6 +414,12 @@ func LicensesEntitlements( } if addon == codersdk.AddonAIGovernance { hasAIGovernanceAddon = true + if entitlement == codersdk.EntitlementEntitled { + aiGovernanceAddonEntitled = true + } + if limit := claims.Features[codersdk.FeatureUserLimit]; limit > aiGovernanceAddonUserLimit { + aiGovernanceAddonUserLimit = limit + } } for _, featureName := range addon.Features() { if _, exists := addonFeatures[featureName]; !exists { @@ -431,6 +444,7 @@ func LicensesEntitlements( // feature observes the write, as do the over-limit warnings below. // Replacing Actual with a fresh allocation would break this. permissionBasedUserCount := false + var legacyActiveUserCount int64 if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) if err != nil { @@ -441,8 +455,22 @@ func LicensesEntitlements( // failure yields a stale count rather than a different one. return entitlements, xerrors.Errorf("count workspace capable users: %w", err) } + legacyActiveUserCount = featureArguments.ActiveUserCount featureArguments.ActiveUserCount = capableCount permissionBasedUserCount = true + + // The user_limit merge keeps the highest limit across all licenses, + // which can come from a license without the addon. Workspace-capable + // counting is priced against the addon license's seat limit, so + // clamp the merged limit down to it. Addon licenses without a + // user_limit claim leave the merged limit untouched. + userLimit := entitlements.Features[codersdk.FeatureUserLimit] + if userLimit.Limit != nil && aiGovernanceAddonUserLimit > 0 && *userLimit.Limit > aiGovernanceAddonUserLimit { + userLimit.Limit = &aiGovernanceAddonUserLimit + // Assigned directly: AddFeature would keep the existing + // higher-limit feature. + entitlements.Features[codersdk.FeatureUserLimit] = userLimit + } } // Now the license specific warnings and errors are added to the entitlements. @@ -555,6 +583,14 @@ func LicensesEntitlements( "Your deployment has %d %s but the license with the limit %d is expired.", featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) } + // Warn about the counting-mode revert while the addon license can + // still be renewed: workspace-capable counting stops at the end of + // the grace period, at which point every active user counts. + if permissionBasedUserCount && !aiGovernanceAddonEntitled { + entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( + "Your license with the AI Governance addon is expired. When it fully expires, all %d active users will count toward the user limit instead of the %d workspace-capable users.", + legacyActiveUserCount, featureArguments.ActiveUserCount)) + } if featureArguments.ActiveAISeatCount > 0 { actual := featureArguments.ActiveAISeatCount feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 26e18adef79..129488b7ef4 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -381,6 +381,35 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { for _, warning := range entitlements.Warnings { require.NotContains(t, warning, "users but") } + // A fully valid addon license must not warn about the + // counting-mode revert. + for _, warning := range entitlements.Warnings { + require.NotContains(t, warning, "fully expires") + } + }) + + t.Run("AddonLimitBindsCounting", func(t *testing.T) { + // A higher user limit from a license without the addon must not + // apply to workspace-capable counting: the merged limit is + // clamped to the addon license's own user limit. + licenses := []database.License{ + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 500}, + }).Valid(now)), + addonLicense(), + } + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 150, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit, + "the non-addon license's higher limit must not apply") + require.Contains(t, entitlements.Warnings, + "Your deployment has 150 workspace-capable users but is only licensed for 100.") }) t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) { @@ -415,6 +444,10 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) require.Contains(t, entitlements.Warnings, "Your deployment has 3 workspace-capable users but the license with the limit 100 is expired.") + // The revert warning gives admins the legacy count they will be + // measured by once the grace period ends. + require.Contains(t, entitlements.Warnings, + "Your license with the AI Governance addon is expired. When it fully expires, all 7 active users will count toward the user limit instead of the 3 workspace-capable users.") }) t.Run("FnErrorPropagates", func(t *testing.T) { From 6b7db3dd81ab02c5ab0af5fc48c7be586d059151 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 20:08:37 +0000 Subject: [PATCH 16/41] chore(enterprise/coderd/license): fix typos-lint spelling and import grouping --- enterprise/coderd/license/license.go | 1 - enterprise/coderd/license/usercount.go | 3 +-- enterprise/coderd/license/usercount_test.go | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 95f28217fc3..97f9fb60de9 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -14,7 +14,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/rbac" diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 964ea549976..0fd3b56e846 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -10,7 +10,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/rbac" @@ -93,7 +92,7 @@ func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Sto // the error, which would fail the count for every user over one // bad row. Role-signature dedupe means this logs once per unique // role set, not once per user sharing it. - logger.Warn(ctx, "user has an unparseable role, counting them as not workspace-capable for license seats", + logger.Warn(ctx, "user has an unparsable role, counting them as not workspace-capable for license seats", slog.F("user_id", row.ID), slog.Error(err), ) diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 129488b7ef4..434e90e857d 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -233,7 +233,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { db, _ := dbtestutil.NewDB(t) org := dbgen.Organization(t, db, database.Organization{}) - // Authorization fails closed on an unparseable stored role, so + // Authorization fails closed on an unparsable stored role, so // this user is not workspace-capable even though their org // membership would otherwise qualify them. corrupt := activeUser(t, db, database.User{RBACRoles: []string{"bad:role:extra"}}) From 031fcc97a572b22104ecdd84e0079b12edfd25db Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 21:32:10 +0000 Subject: [PATCH 17/41] test(enterprise/coderd/license): benchmark seat counting across user 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. --- .../coderd/license/usercount_bench_test.go | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 enterprise/coderd/license/usercount_bench_test.go diff --git a/enterprise/coderd/license/usercount_bench_test.go b/enterprise/coderd/license/usercount_bench_test.go new file mode 100644 index 00000000000..35d6dcf527b --- /dev/null +++ b/enterprise/coderd/license/usercount_bench_test.go @@ -0,0 +1,226 @@ +package license_test + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/enterprise/coderd/license" +) + +// BenchmarkCountWorkspaceCapableUsers measures how permission-based seat +// counting scales along its two cost axes: the number of eligible active +// users (row fetch and per-row signature work) and the number of unique +// role sets (role expansion and rego evaluation, one per set). +// +// Scenarios (users/orgs/roles are seeded in bulk via SQL): +// +// - Uniform: one org, half gateways, half workspace-capable. Unique +// role sets stay constant, so this isolates per-user row cost. +// - ManyOrgs: users spread evenly across orgs, plain members. Unique +// role sets scale with org count. +// - UniquePairs: every user belongs to a distinct pair of orgs, so +// every user is a unique role set. Worst-case rego evaluation with +// builtin roles only. +// - CustomRoles: users hold org-scoped custom roles round-robin. +// Exercises the custom-role prefetch and expansion path. +// +// Run with: +// +// go test ./enterprise/coderd/license/ -bench BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$' -v +func BenchmarkCountWorkspaceCapableUsers(b *testing.B) { + // The gateway-accounts use case: workspace-create flows only through + // explicit grants, so capability actually varies between users. + rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) + b.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) + + ctx := context.Background() + authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + // Discard logs: the per-count Info line and its fields are not what + // is being measured. + logger := slog.Make() + + for _, scenario := range []benchScenario{ + {name: "Uniform/1k", users: 1_000, orgs: 1}, + {name: "Uniform/10k", users: 10_000, orgs: 1}, + {name: "Uniform/50k", users: 50_000, orgs: 1}, + {name: "ManyOrgs/10k-100orgs", users: 10_000, orgs: 100}, + {name: "UniquePairs/10k", users: 10_000, orgs: 100, uniquePairs: true}, + {name: "CustomRoles/10k-1000roles", users: 10_000, orgs: 10, customRolesPerOrg: 100}, + } { + b.Run(scenario.name, func(b *testing.B) { + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(b) + seedBenchUsers(b, ctx, db, sqlDB, scenario) + + b.ResetTimer() + var count int64 + for i := 0; i < b.N; i++ { + var err error + count, err = license.CountWorkspaceCapableUsers(ctx, logger, db, authorizer) + require.NoError(b, err) + } + b.StopTimer() + require.NotZero(b, count, "scenario must produce capable users") + b.ReportMetric(float64(scenario.users), "users") + b.ReportMetric(float64(count), "capable") + }) + } +} + +type benchScenario struct { + name string + users int + orgs int + // uniquePairs gives every user memberships in a distinct pair of + // orgs, making every user a unique role set. + uniquePairs bool + // customRolesPerOrg grants each user one org-scoped custom role, + // assigned round-robin. + customRolesPerOrg int +} + +// seedBenchUsers bulk-inserts active users and their org memberships. +// Deterministic UUIDs (zero-prefixed, numbered) let memberships be +// generated from the same series without returning inserted rows. +func seedBenchUsers(b *testing.B, ctx context.Context, db database.Store, sqlDB *sql.DB, s benchScenario) { + b.Helper() + + orgIDs := make([]uuid.UUID, s.orgs) + for i := range orgIDs { + org := dbgen.Organization(b, db, database.Organization{}) + emptyOrgDefaultRoles(b, ctx, db, org) + orgIDs[i] = org.ID + } + + // Deterministic user IDs let membership rows be generated from the + // same series without returning inserted rows. + _, err := sqlDB.ExecContext(ctx, ` + CREATE OR REPLACE FUNCTION benchUserID(i bigint) RETURNS uuid AS $$ + SELECT ('00000000-0000-0000-0000-' || lpad(i::text, 12, '0'))::uuid + $$ LANGUAGE sql IMMUTABLE; + `) + require.NoError(b, err) + + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO users (id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type) + SELECT + benchUserID(i), + 'bench-' || i || '@example.com', + 'bench-' || i, + '\x'::bytea, + now(), now(), + 'active'::user_status, + '{}'::text[], + 'password'::login_type + FROM generate_series(1, $1) AS i; + `, s.users) + require.NoError(b, err) + + orgIDText := make([]string, len(orgIDs)) + for i, id := range orgIDs { + orgIDText[i] = id.String() + } + + switch { + case s.uniquePairs: + // Membership in orgs (i mod K) and (i/K mod K): distinct pairs, + // hence distinct role sets, for up to K^2 users. Even users hold + // the workspace-access grant in their first org so capability + // varies across the population. + require.GreaterOrEqual(b, s.orgs*s.orgs, s.users, "not enough org pairs for unique role sets") + _, err = sqlDB.ExecContext(ctx, fmt.Sprintf(` + INSERT INTO organization_members (user_id, organization_id, created_at, updated_at, roles) + SELECT benchUserID(i), ($3::uuid[])[(i %% $2) + 1], now(), now(), + CASE WHEN i %% 2 = 0 THEN ARRAY['%s']::text[] ELSE '{}'::text[] END + FROM generate_series(1, $1) AS i + ON CONFLICT DO NOTHING; + `, rbac.RoleOrgWorkspaceAccess()), s.users, s.orgs, pqStringArray(orgIDText)) + require.NoError(b, err) + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO organization_members (user_id, organization_id, created_at, updated_at, roles) + SELECT benchUserID(i), ($3::uuid[])[((i / $2) % $2) + 1], now(), now(), '{}'::text[] + FROM generate_series(1, $1) AS i + ON CONFLICT DO NOTHING; + `, s.users, s.orgs, pqStringArray(orgIDText)) + require.NoError(b, err) + case s.customRolesPerOrg > 0: + // One workspace-create custom role per (org, slot), granted + // round-robin: users cycle through orgs, and within an org + // through its roles. + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO custom_roles (name, display_name, organization_id, org_permissions) + SELECT + 'bench-role-' || slot, + 'Bench Role ' || slot, + ($2::uuid[])[(slot % $3) + 1], + '[{"negate":false,"resource_type":"workspace","action":"create"}]'::jsonb + FROM generate_series(0, $1 - 1) AS slot; + `, s.orgs*s.customRolesPerOrg, pqStringArray(orgIDText), s.orgs) + require.NoError(b, err) + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO organization_members (user_id, organization_id, created_at, updated_at, roles) + SELECT + benchUserID(i), + ($2::uuid[])[(i % $3) + 1], + now(), now(), + ARRAY['bench-role-' || ((i % ($3 * $4) / $3) * $3 + (i % $3))]::text[] + FROM generate_series(1, $1) AS i; + `, s.users, pqStringArray(orgIDText), s.orgs, s.customRolesPerOrg) + require.NoError(b, err) + default: + // Round-robin plain membership; every even user additionally + // holds the workspace-access grant so capability varies. + _, err = sqlDB.ExecContext(ctx, fmt.Sprintf(` + INSERT INTO organization_members (user_id, organization_id, created_at, updated_at, roles) + SELECT + benchUserID(i), + ($2::uuid[])[(i %% $3) + 1], + now(), now(), + CASE WHEN i %% 2 = 0 THEN ARRAY['%s']::text[] ELSE '{}'::text[] END + FROM generate_series(1, $1) AS i; + `, rbac.RoleOrgWorkspaceAccess()), s.users, pqStringArray(orgIDText), s.orgs) + require.NoError(b, err) + } + + // Bulk inserts leave planner statistics claiming near-empty tables, + // which makes the roles query fall into a nested-loop plan that + // re-executes its aggregation per user row. Refresh them the way + // autovacuum would have in a live deployment. + _, err = sqlDB.ExecContext(ctx, `ANALYZE users; ANALYZE organization_members; ANALYZE organizations; ANALYZE custom_roles;`) + require.NoError(b, err) +} + +func emptyOrgDefaultRoles(b *testing.B, ctx context.Context, db database.Store, org database.Organization) { + b.Helper() + _, err := db.UpdateOrganization(ctx, database.UpdateOrganizationParams{ + ID: org.ID, + UpdatedAt: org.UpdatedAt, + Name: org.Name, + DisplayName: org.DisplayName, + Description: org.Description, + Icon: org.Icon, + DefaultOrgMemberRoles: []string{}, + }) + require.NoError(b, err) +} + +func pqStringArray(elems []string) string { + out := "{" + for i, e := range elems { + if i > 0 { + out += "," + } + out += e + } + return out + "}" +} From 9f363f4c34fb2355e390895d201b3df8235b4e17 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 16 Jul 2026 21:34:13 +0000 Subject: [PATCH 18/41] fix(enterprise/coderd/license): bound the workspace-capable count with 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. --- enterprise/coderd/license/license.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 97f9fb60de9..bb6143216e3 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -20,6 +20,12 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// Exceeding this timeout fails the entitlements computation; the caller +// keeps serving the previous entitlements. The count normally completes +// in well under a second, but its cost scales with the number of unique +// role sets and it runs on a context with no deadline of its own. +const workspaceCapableUserCountTimeout = 60 * time.Second + // Entitlements processes licenses to return whether features are enabled or not. // TODO(@deansheather): This function and the related LicensesEntitlements // function should be refactored into smaller functions that: @@ -60,6 +66,8 @@ func Entitlements( var workspaceCapableUserCountFn WorkspaceCapableUserCountFn if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) + defer cancel() return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) } } From 51e0d2431cc803169d2efc4b5377fa1d9877fc1f Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Sun, 19 Jul 2026 23:07:38 +0000 Subject: [PATCH 19/41] chore(codersdk): drop ExperimentsSafe exclusion comment --- codersdk/deployment.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 24db9b11713..ce5e594bc9c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5324,9 +5324,6 @@ var ExperimentsKnown = Experiments{ // not be included here and will be essentially hidden. var ExperimentsSafe = Experiments{ ExperimentMinimumImplicitMember, - // ExperimentPermissionBasedLicensing is deliberately excluded: it - // changes how license seats are counted, so enabling it must be an - // explicit opt-in rather than a side effect of --experiments='*'. } // Experiments is a list of experiments. From 1bd30093e26a48e95618de35e0f2e80475e5e56a Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 20 Jul 2026 00:33:20 +0000 Subject: [PATCH 20/41] chore: trim seat-counting comments to describe mechanism over motivation 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. --- coderd/database/querier.go | 11 ++++--- coderd/database/queries.sql.go | 16 +++++----- coderd/database/queries/users.sql | 16 +++++----- enterprise/coderd/license/license.go | 29 +++++++++---------- enterprise/coderd/license/usercount.go | 16 ++++------ .../coderd/license/usercount_bench_test.go | 4 +-- 6 files changed, 43 insertions(+), 49 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 860b3c838f7..f8af4aaa668 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -342,12 +342,11 @@ type sqlcQuerier interface { GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) // Returns the authorization roles (site and org-scoped, including implied - // member roles and organization default roles) for every user eligible for - // license seat counting: active, not deleted, and neither a system user nor - // a service account. Used by permission-based license seat counting to - // evaluate workspace-create capability. Group memberships are intentionally - // not returned: they only influence authorization through object ACL - // matching, and the seat-count evaluation uses objects without ACLs. + // member roles and organization default roles) for every active, non-deleted + // user who is neither a system user nor a service account, matching the + // GetActiveUserCount population. Group memberships are not returned, so the + // results only support authorization decisions on objects without ACLs: + // groups influence authorization solely through object ACL matching. GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) // For PG Coordinator HTMLDebug diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f639bd66c02..89a6c14f535 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30190,8 +30190,9 @@ WITH org_roles AS ( -- too, but their rows never survive the join against the outer -- WHERE, so the organization-service-account case does not apply. -- - -- organizations.default_org_member_roles is unioned in so changes - -- to org defaults propagate on the next entitlement refresh. + -- organizations.default_org_member_roles applies to every member + -- but is not materialized on membership rows, so it is unioned in + -- here. unnest( array_cat( array_append(organization_members.roles, 'organization-member'), @@ -30225,12 +30226,11 @@ type GetActiveUsersAuthorizationRolesRow struct { } // Returns the authorization roles (site and org-scoped, including implied -// member roles and organization default roles) for every user eligible for -// license seat counting: active, not deleted, and neither a system user nor -// a service account. Used by permission-based license seat counting to -// evaluate workspace-create capability. Group memberships are intentionally -// not returned: they only influence authorization through object ACL -// matching, and the seat-count evaluation uses objects without ACLs. +// member roles and organization default roles) for every active, non-deleted +// user who is neither a system user nor a service account, matching the +// GetActiveUserCount population. Group memberships are not returned, so the +// results only support authorization decisions on objects without ACLs: +// groups influence authorization solely through object ACL matching. func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) { rows, err := q.db.QueryContext(ctx, getActiveUsersAuthorizationRoles) if err != nil { diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 5d3e4d7e41f..86fc30a56df 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -655,12 +655,11 @@ WHERE -- name: GetActiveUsersAuthorizationRoles :many -- Returns the authorization roles (site and org-scoped, including implied --- member roles and organization default roles) for every user eligible for --- license seat counting: active, not deleted, and neither a system user nor --- a service account. Used by permission-based license seat counting to --- evaluate workspace-create capability. Group memberships are intentionally --- not returned: they only influence authorization through object ACL --- matching, and the seat-count evaluation uses objects without ACLs. +-- member roles and organization default roles) for every active, non-deleted +-- user who is neither a system user nor a service account, matching the +-- GetActiveUserCount population. Group memberships are not returned, so the +-- results only support authorization decisions on objects without ACLs: +-- groups influence authorization solely through object ACL matching. WITH org_roles AS ( SELECT organization_members.user_id, @@ -675,8 +674,9 @@ WITH org_roles AS ( -- too, but their rows never survive the join against the outer -- WHERE, so the organization-service-account case does not apply. -- - -- organizations.default_org_member_roles is unioned in so changes - -- to org defaults propagate on the next entitlement refresh. + -- organizations.default_org_member_roles applies to every member + -- but is not materialized on membership rows, so it is unioned in + -- here. unnest( array_cat( array_append(organization_members.roles, 'organization-member'), diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index bb6143216e3..ee06128bf05 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -58,11 +58,10 @@ func Entitlements( } // Permission-based licensing counts only users the RBAC engine - // authorizes to create workspaces. Users without workspace-create - // capability ("gateway accounts") do not consume seats. The count is - // resolved lazily by LicensesEntitlements, and only when a valid - // license carries the AI Governance addon; deployments without the - // addon always use the plain active user count. + // authorizes to create workspaces. The count is resolved lazily by + // LicensesEntitlements, and only when a valid license carries the AI + // Governance addon; deployments without the addon always use the + // plain active user count. var workspaceCapableUserCountFn WorkspaceCapableUserCountFn if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { @@ -442,9 +441,8 @@ func LicensesEntitlements( } } - // Permission-based seat counting. When a valid license carries the AI - // Governance addon, only workspace-capable users consume user_limit - // seats; gateway accounts (users without workspace-create) are free. + // Permission-based seat counting: when a valid license carries the AI + // Governance addon, user_limit counts only workspace-capable users. // The resolved count overwrites featureArguments.ActiveUserCount, // which the user_limit feature's Actual pointer aliases: Feature // values copy the pointer, not the int64, so every copy of the @@ -467,10 +465,11 @@ func LicensesEntitlements( permissionBasedUserCount = true // The user_limit merge keeps the highest limit across all licenses, - // which can come from a license without the addon. Workspace-capable - // counting is priced against the addon license's seat limit, so - // clamp the merged limit down to it. Addon licenses without a - // user_limit claim leave the merged limit untouched. + // which can come from a license without the addon. Clamp the merged + // limit to the addon license's own user_limit so a non-addon + // license's higher limit does not apply to the workspace-capable + // count. Addon licenses without a user_limit claim leave the merged + // limit untouched. userLimit := entitlements.Features[codersdk.FeatureUserLimit] if userLimit.Limit != nil && aiGovernanceAddonUserLimit > 0 && *userLimit.Limit > aiGovernanceAddonUserLimit { userLimit.Limit = &aiGovernanceAddonUserLimit @@ -590,9 +589,9 @@ func LicensesEntitlements( "Your deployment has %d %s but the license with the limit %d is expired.", featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) } - // Warn about the counting-mode revert while the addon license can - // still be renewed: workspace-capable counting stops at the end of - // the grace period, at which point every active user counts. + // The addon exists only on grace-period licenses: warn that + // workspace-capable counting stops at the end of the grace period, + // at which point every active user counts. if permissionBasedUserCount && !aiGovernanceAddonEntitled { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your license with the AI Governance addon is expired. When it fully expires, all %d active users will count toward the user limit instead of the %d workspace-capable users.", diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 0fd3b56e846..84bee0ef72b 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -47,8 +47,7 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data // cannot change the result. Deduplicate on the role signature so // evaluation cost scales with the number of unique role sets, not the // number of users. TestWorkspaceCreateIgnoresGroups enforces the - // group-independence assumption; if it ever breaks, groups must be - // added back to the subject, the signature, and the query. + // group-independence assumption. capableBySignature := make(map[string]bool) var count int64 for _, row := range rows { @@ -66,10 +65,8 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data } } - // This line doubles as the counting-mode signal: it is emitted only - // when permission-based seat counting ran, and it carries both the - // counted seats and the total eligible active users so the two can be - // compared when diagnosing seat disputes. + // Emitted only when permission-based counting runs, so the line's + // presence identifies the counting mode. logger.Info(ctx, "counted workspace-capable users for license seats", slog.F("workspace_capable_users", count), slog.F("active_users", len(rows)), @@ -88,10 +85,9 @@ func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Sto if err != nil { // A stored role string that fails to parse grants nothing: // authorization fails closed on it, so this user cannot create a - // workspace. Treat the user as not capable rather than returning - // the error, which would fail the count for every user over one - // bad row. Role-signature dedupe means this logs once per unique - // role set, not once per user sharing it. + // workspace. Treat the user as not capable instead of failing the + // entire count. Logged once per unique role set due to the + // signature dedupe. logger.Warn(ctx, "user has an unparsable role, counting them as not workspace-capable for license seats", slog.F("user_id", row.ID), slog.Error(err), diff --git a/enterprise/coderd/license/usercount_bench_test.go b/enterprise/coderd/license/usercount_bench_test.go index 35d6dcf527b..84f7d644595 100644 --- a/enterprise/coderd/license/usercount_bench_test.go +++ b/enterprise/coderd/license/usercount_bench_test.go @@ -39,8 +39,8 @@ import ( // // go test ./enterprise/coderd/license/ -bench BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$' -v func BenchmarkCountWorkspaceCapableUsers(b *testing.B) { - // The gateway-accounts use case: workspace-create flows only through - // explicit grants, so capability actually varies between users. + // Workspace-create flows only through explicit grants under + // MinimumImplicitMember, so capability actually varies between users. rbac.ReloadBuiltinRoles(&rbac.RoleOptions{MinimumImplicitMember: true}) b.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) From 2e7c1cca7102d35d99de8ea199e94fbfaea20326 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 20 Jul 2026 00:48:28 +0000 Subject: [PATCH 21/41] chore(enterprise/coderd/license): put context first in benchmark helper signatures --- enterprise/coderd/license/usercount_bench_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/license/usercount_bench_test.go b/enterprise/coderd/license/usercount_bench_test.go index 84f7d644595..aa065dfac76 100644 --- a/enterprise/coderd/license/usercount_bench_test.go +++ b/enterprise/coderd/license/usercount_bench_test.go @@ -60,7 +60,7 @@ func BenchmarkCountWorkspaceCapableUsers(b *testing.B) { } { b.Run(scenario.name, func(b *testing.B) { db, _, sqlDB := dbtestutil.NewDBWithSQLDB(b) - seedBenchUsers(b, ctx, db, sqlDB, scenario) + seedBenchUsers(ctx, b, db, sqlDB, scenario) b.ResetTimer() var count int64 @@ -92,13 +92,13 @@ type benchScenario struct { // seedBenchUsers bulk-inserts active users and their org memberships. // Deterministic UUIDs (zero-prefixed, numbered) let memberships be // generated from the same series without returning inserted rows. -func seedBenchUsers(b *testing.B, ctx context.Context, db database.Store, sqlDB *sql.DB, s benchScenario) { +func seedBenchUsers(ctx context.Context, b *testing.B, db database.Store, sqlDB *sql.DB, s benchScenario) { b.Helper() orgIDs := make([]uuid.UUID, s.orgs) for i := range orgIDs { org := dbgen.Organization(b, db, database.Organization{}) - emptyOrgDefaultRoles(b, ctx, db, org) + emptyOrgDefaultRoles(ctx, b, db, org) orgIDs[i] = org.ID } @@ -200,7 +200,7 @@ func seedBenchUsers(b *testing.B, ctx context.Context, db database.Store, sqlDB require.NoError(b, err) } -func emptyOrgDefaultRoles(b *testing.B, ctx context.Context, db database.Store, org database.Organization) { +func emptyOrgDefaultRoles(ctx context.Context, b *testing.B, db database.Store, org database.Organization) { b.Helper() _, err := db.UpdateOrganization(ctx, database.UpdateOrganizationParams{ ID: org.ID, From 45bcc6b7788f3958a335b7611ba3bd3fe8393bae Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 20 Jul 2026 15:28:33 +0000 Subject: [PATCH 22/41] fix(enterprise/coderd/license): select the best user_limit pair across 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. --- enterprise/coderd/license/license.go | 170 +++++++++++++------- enterprise/coderd/license/usercount_test.go | 64 ++++++-- 2 files changed, 163 insertions(+), 71 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ee06128bf05..ba9e1dd18fa 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -131,9 +131,11 @@ type FeatureArguments struct { ManagedAgentCountFn ManagedAgentCountFn // WorkspaceCapableUserCountFn returns the number of active users the // RBAC engine authorizes to create workspaces. It is invoked only when - // a valid license carries the AI Governance addon; the result then - // replaces ActiveUserCount for the user_limit feature and its - // warnings. May be nil, in which case ActiveUserCount is always used. + // a valid license carries both the AI Governance addon and a + // user_limit claim; the result then applies to that license's + // user_limit candidate, and replaces ActiveUserCount when such a + // candidate is selected for enforcement. May be nil, in which case + // ActiveUserCount is always used. WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn } @@ -141,6 +143,36 @@ type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) +// userLimitCandidate is one license's user_limit terms: its seat limit, +// its entitlement, and the counting mode implied by whether the license +// carries the AI Governance addon. +type userLimitCandidate struct { + limit int64 + entitlement codersdk.Entitlement + aiGovernanceAddon bool +} + +// betterUserLimit reports whether candidate a is more favorable than b, +// where countA and countB are the user counts each candidate's mode +// implies. Ordering mirrors Feature.Compare: a candidate whose count is +// within its limit beats one whose count is not, then higher entitlement, +// then higher limit; the addon mode breaks remaining ties since its +// count is never larger than the active user count. +func betterUserLimit(a, b userLimitCandidate, countA, countB int64) bool { + compliantA := countA <= a.limit + compliantB := countB <= b.limit + if compliantA != compliantB { + return compliantA + } + if a.entitlement.Weight() != b.entitlement.Weight() { + return a.entitlement.Weight() > b.entitlement.Weight() + } + if a.limit != b.limit { + return a.limit > b.limit + } + return a.aiGovernanceAddon && !b.aiGovernanceAddon +} + // LicensesEntitlements returns the entitlements for licenses. Entitlements are // merged from all licenses and the highest entitlement is used for each feature. // Arguments: @@ -165,17 +197,12 @@ func LicensesEntitlements( // suppress the soft warning for AI Bridge GA. hasExplicitAIBridgeEntitlement := false - // Track whether any valid license carries the AI Governance addon with - // its dependencies satisfied. This gates permission-based seat - // counting below. - hasAIGovernanceAddon := false - // aiGovernanceAddonEntitled is true when at least one addon-carrying - // license is fully valid rather than in its grace period. - aiGovernanceAddonEntitled := false - // aiGovernanceAddonUserLimit is the highest user_limit claimed by an - // addon-carrying license. Permission-based counting is enforced - // against this limit rather than limits from non-addon licenses. - var aiGovernanceAddonUserLimit int64 + // Each valid license's user_limit claim forms a candidate pairing of + // seat limit and counting mode: licenses carrying the AI Governance + // addon count workspace-capable users, others count all active users. + // The most favorable candidate is selected once all licenses are + // processed. + var userLimitCandidates []userLimitCandidate // Default all entitlements to be disabled. entitlements := codersdk.Entitlements{ @@ -403,6 +430,7 @@ func LicensesEntitlements( } addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature) + licenseHasAIGovernanceAddon := false // Finally, add all features from the addons. We do this last so that // any dependencies of an addon are validated against the calculated @@ -419,13 +447,7 @@ func LicensesEntitlements( continue } if addon == codersdk.AddonAIGovernance { - hasAIGovernanceAddon = true - if entitlement == codersdk.EntitlementEntitled { - aiGovernanceAddonEntitled = true - } - if limit := claims.Features[codersdk.FeatureUserLimit]; limit > aiGovernanceAddonUserLimit { - aiGovernanceAddonUserLimit = limit - } + licenseHasAIGovernanceAddon = true } for _, featureName := range addon.Features() { if _, exists := addonFeatures[featureName]; !exists { @@ -439,44 +461,84 @@ func LicensesEntitlements( for featureName, feature := range addonFeatures { entitlements.AddFeature(featureName, feature) } + + if limit := claims.Features[codersdk.FeatureUserLimit]; limit > 0 { + userLimitCandidates = append(userLimitCandidates, userLimitCandidate{ + limit: limit, + entitlement: entitlement, + aiGovernanceAddon: licenseHasAIGovernanceAddon, + }) + } } - // Permission-based seat counting: when a valid license carries the AI - // Governance addon, user_limit counts only workspace-capable users. - // The resolved count overwrites featureArguments.ActiveUserCount, - // which the user_limit feature's Actual pointer aliases: Feature - // values copy the pointer, not the int64, so every copy of the - // feature observes the write, as do the over-limit warnings below. - // Replacing Actual with a fresh allocation would break this. + // Best-pair user_limit selection. Every candidate is evaluated against + // the count its own license's mode implies, so one license's limit is + // never combined with another license's counting mode. A candidate + // satisfied by its count wins over any unsatisfied one. + // + // When an addon candidate is selected, the capable count overwrites + // featureArguments.ActiveUserCount, which the user_limit feature's + // Actual pointer aliases: Feature values copy the pointer, not the + // int64, so every copy of the feature observes the write, as do the + // over-limit warnings below. Replacing Actual with a fresh allocation + // would break this. permissionBasedUserCount := false var legacyActiveUserCount int64 - if hasAIGovernanceAddon && featureArguments.WorkspaceCapableUserCountFn != nil { - capableCount, err := featureArguments.WorkspaceCapableUserCountFn(ctx) - if err != nil { - // A failed seat count is deliberately a hard failure rather - // than a recorded entitlement error: continuing with - // ActiveUserCount would silently change what user_limit - // measures. The caller keeps the previous entitlements, so a - // failure yields a stale count rather than a different one. - return entitlements, xerrors.Errorf("count workspace capable users: %w", err) - } - legacyActiveUserCount = featureArguments.ActiveUserCount - featureArguments.ActiveUserCount = capableCount - permissionBasedUserCount = true - - // The user_limit merge keeps the highest limit across all licenses, - // which can come from a license without the addon. Clamp the merged - // limit to the addon license's own user_limit so a non-addon - // license's higher limit does not apply to the workspace-capable - // count. Addon licenses without a user_limit claim leave the merged - // limit untouched. - userLimit := entitlements.Features[codersdk.FeatureUserLimit] - if userLimit.Limit != nil && aiGovernanceAddonUserLimit > 0 && *userLimit.Limit > aiGovernanceAddonUserLimit { - userLimit.Limit = &aiGovernanceAddonUserLimit - // Assigned directly: AddFeature would keep the existing - // higher-limit feature. - entitlements.Features[codersdk.FeatureUserLimit] = userLimit + aiGovernanceAddonEntitled := false + if len(userLimitCandidates) > 0 { + hasAddonCandidate := false + for _, c := range userLimitCandidates { + if c.aiGovernanceAddon { + hasAddonCandidate = true + if c.entitlement == codersdk.EntitlementEntitled { + aiGovernanceAddonEntitled = true + } + } + } + + var capableCount int64 + capableCountValid := false + if hasAddonCandidate && featureArguments.WorkspaceCapableUserCountFn != nil { + count, err := featureArguments.WorkspaceCapableUserCountFn(ctx) + if err != nil { + // A failed seat count is deliberately a hard failure rather + // than a recorded entitlement error: continuing with + // ActiveUserCount would silently change what user_limit + // measures. The caller keeps the previous entitlements, so a + // failure yields a stale count rather than a different one. + return entitlements, xerrors.Errorf("count workspace capable users: %w", err) + } + capableCount = count + capableCountValid = true + } + countFor := func(c userLimitCandidate) int64 { + if c.aiGovernanceAddon && capableCountValid { + return capableCount + } + return featureArguments.ActiveUserCount + } + + best := userLimitCandidates[0] + for _, c := range userLimitCandidates[1:] { + if betterUserLimit(c, best, countFor(c), countFor(best)) { + best = c + } } + + if best.aiGovernanceAddon && capableCountValid { + legacyActiveUserCount = featureArguments.ActiveUserCount + featureArguments.ActiveUserCount = capableCount + permissionBasedUserCount = true + } + + // AddFeature merged limits and entitlements across licenses without + // pairing them to counting modes; overwrite the merged terms with + // the selected candidate's. Actual keeps aliasing + // featureArguments.ActiveUserCount. + userLimit := entitlements.Features[codersdk.FeatureUserLimit] + userLimit.Limit = &best.limit + userLimit.Entitlement = best.entitlement + entitlements.Features[codersdk.FeatureUserLimit] = userLimit } // Now the license specific warnings and errors are added to the entitlements. diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 434e90e857d..e95dac2c7fa 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -388,28 +388,58 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { } }) - t.Run("AddonLimitBindsCounting", func(t *testing.T) { - // A higher user limit from a license without the addon must not - // apply to workspace-capable counting: the merged limit is - // clamped to the addon license's own user limit. + t.Run("BestPairSelection", func(t *testing.T) { + // A deployment holding both an addon license and a non-addon + // license has two user_limit candidates, each evaluated with + // its own counting mode. Limits and modes never mix. licenses := []database.License{ dbLicense(*(&coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureUserLimit: 500}, + Features: license.Features{codersdk.FeatureUserLimit: 200}, }).Valid(now)), - addonLicense(), + addonLicense(), // user_limit 100, AI Governance addon. } - entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, - WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { - return 150, nil - }, + run := func(t *testing.T, activeUsers, capableUsers int64) codersdk.Entitlements { + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: activeUsers, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return capableUsers, nil + }, + }) + require.NoError(t, err) + return entitlements + } + + t.Run("LegacyPairCompliant", func(t *testing.T) { + // 180 active <= 200 wins over 150 capable > 100: the + // non-addon license keeps the deployment compliant. + entitlements := run(t, 180, 150) + require.Equal(t, int64(180), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(200), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + for _, warning := range entitlements.Warnings { + require.NotContains(t, warning, "users but") + } + }) + + t.Run("AddonPairCompliant", func(t *testing.T) { + // 90 capable <= 100 wins over 250 active > 200: the addon + // license keeps the deployment compliant. + entitlements := run(t, 250, 90) + require.Equal(t, int64(90), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + for _, warning := range entitlements.Warnings { + require.NotContains(t, warning, "users but") + } + }) + + t.Run("NeitherPairCompliant", func(t *testing.T) { + // Both pairs over: the higher limit is reported, with the + // counting mode of its own license. + entitlements := run(t, 250, 150) + require.Equal(t, int64(250), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(200), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + require.Contains(t, entitlements.Warnings, + "Your deployment has 250 active users but is only licensed for 200.") }) - require.NoError(t, err) - require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual) - require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit, - "the non-addon license's higher limit must not apply") - require.Contains(t, entitlements.Warnings, - "Your deployment has 150 workspace-capable users but is only licensed for 100.") }) t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) { From 97b3fd040d18435276f68d8ecd7305f04661ed68 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 20 Jul 2026 15:41:11 +0000 Subject: [PATCH 23/41] test(enterprise/coderd/license): cover best-pair user_limit selection 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. --- .../coderd/license/license_internal_test.go | 194 ++++++------------ enterprise/coderd/license/usercount_test.go | 49 +++++ 2 files changed, 116 insertions(+), 127 deletions(-) diff --git a/enterprise/coderd/license/license_internal_test.go b/enterprise/coderd/license/license_internal_test.go index 616f0b5b989..19ca81b02e8 100644 --- a/enterprise/coderd/license/license_internal_test.go +++ b/enterprise/coderd/license/license_internal_test.go @@ -2,139 +2,79 @@ package license import ( "testing" - "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" ) -func TestNextLicenseValidityPeriod(t *testing.T) { +func TestBetterUserLimit(t *testing.T) { t.Parallel() - t.Run("Apply", func(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - - licensePeriods [][2]time.Time - expectedPeriods [][2]time.Time - }{ - { - name: "None", - licensePeriods: [][2]time.Time{}, - expectedPeriods: [][2]time.Time{}, - }, - { - name: "One", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "TwoOverlapping", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "TwoNonOverlapping", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "ThreeOverlapping", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "ThreeNonOverlapping", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "PeriodContainsAnotherPeriod", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, - {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: [][2]time.Time{ - {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, - }, - }, - { - name: "EndBeforeStart", - licensePeriods: [][2]time.Time{ - {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, - }, - expectedPeriods: nil, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + cand := func(limit int64, entitlement codersdk.Entitlement, addon bool) userLimitCandidate { + return userLimitCandidate{limit: limit, entitlement: entitlement, aiGovernanceAddon: addon} + } + entitled := codersdk.EntitlementEntitled + grace := codersdk.EntitlementGracePeriod - // Test with all possible permutations of the periods to ensure - // consistency regardless of the order. - ps := permutations(tc.licensePeriods) - for _, p := range ps { - t.Logf("permutation: %v", p) - period := &licenseValidityPeriod{} - for _, times := range p { - t.Logf("applying %v", times) - period.Apply(times[0], times[1]) - } - assert.Equal(t, tc.expectedPeriods, period.merged(), "merged") - } - }) - } - }) -} + cases := []struct { + name string + a, b userLimitCandidate + countA, countB int64 + want bool + }{ + { + name: "ComplianceBeatsEntitlement", + a: cand(200, grace, false), countA: 150, + b: cand(100, entitled, false), countB: 150, + want: true, + }, + { + name: "ComplianceBeatsHigherLimit", + a: cand(100, entitled, true), countA: 90, + b: cand(200, entitled, false), countB: 250, + want: true, + }, + { + name: "EntitlementBeatsLimitWhenBothCompliant", + a: cand(100, entitled, false), countA: 50, + b: cand(200, grace, false), countB: 50, + want: true, + }, + { + name: "HigherLimitWinsWhenBothCompliantAndEqualEntitlement", + a: cand(200, entitled, false), countA: 50, + b: cand(100, entitled, false), countB: 50, + want: true, + }, + { + name: "HigherLimitWinsWhenBothOver", + a: cand(200, entitled, false), countA: 250, + b: cand(100, entitled, true), countB: 150, + want: true, + }, + { + name: "AddonBreaksExactTies", + a: cand(100, entitled, true), countA: 50, + b: cand(100, entitled, false), countB: 80, + want: true, + }, + { + name: "EqualCandidatesAreNotBetter", + a: cand(100, entitled, false), countA: 50, + b: cand(100, entitled, false), countB: 50, + want: false, + }, + } -func permutations[T any](arr []T) [][]T { - var res [][]T - var helper func([]T, int) - helper = func(a []T, i int) { - if i == len(a)-1 { - // make a copy before appending - tmp := make([]T, len(a)) - copy(tmp, a) - res = append(res, tmp) - return - } - for j := i; j < len(a); j++ { - a[i], a[j] = a[j], a[i] - helper(a, i+1) - a[i], a[j] = a[j], a[i] // backtrack - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, betterUserLimit(tc.a, tc.b, tc.countA, tc.countB)) + if tc.want { + require.False(t, betterUserLimit(tc.b, tc.a, tc.countB, tc.countA), + "strict ordering must not hold both ways") + } + }) } - helper(arr, 0) - return res } diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index e95dac2c7fa..103eb9e9e1d 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -440,6 +440,55 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Contains(t, entitlements.Warnings, "Your deployment has 250 active users but is only licensed for 200.") }) + + t.Run("GraceAddonCompliantBeatsEntitledOver", func(t *testing.T) { + // A grace-period addon pair that fits its count wins over an + // entitled non-addon pair that does not, carrying its grace + // entitlement and the revert warning with it. + licenses := []database.License{ + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 200}, + }).Valid(now)), + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).GracePeriod(now).AIGovernanceAddon(10)), + } + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 250, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 90, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(90), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) + require.Contains(t, entitlements.Warnings, + "Your deployment has 90 workspace-capable users but the license with the limit 100 is expired.") + require.Contains(t, entitlements.Warnings, + "Your license with the AI Governance addon is expired. When it fully expires, all 250 active users will count toward the user limit instead of the 90 workspace-capable users.") + }) + + t.Run("EqualLimitsPreferAddon", func(t *testing.T) { + // Identical limit and entitlement on an addon and a + // non-addon license: the addon pair wins the tie, so the + // workspace-capable count is displayed. + licenses := []database.License{ + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).Valid(now)), + addonLicense(), + } + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 80, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 30, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(30), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + }) }) t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) { From e3b83852affc5e9a33003f30120900a1b479893d Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 20 Jul 2026 15:42:22 +0000 Subject: [PATCH 24/41] test(enterprise/coderd/license): restore TestNextLicenseValidityPeriod The previous commit unintentionally replaced license_internal_test.go when adding TestBetterUserLimit; restore it and move the new test to its own file. --- .../coderd/license/license_internal_test.go | 194 ++++++++++++------ .../coderd/license/userlimit_internal_test.go | 80 ++++++++ 2 files changed, 207 insertions(+), 67 deletions(-) create mode 100644 enterprise/coderd/license/userlimit_internal_test.go diff --git a/enterprise/coderd/license/license_internal_test.go b/enterprise/coderd/license/license_internal_test.go index 19ca81b02e8..616f0b5b989 100644 --- a/enterprise/coderd/license/license_internal_test.go +++ b/enterprise/coderd/license/license_internal_test.go @@ -2,79 +2,139 @@ package license import ( "testing" + "time" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/codersdk" + "github.com/stretchr/testify/assert" ) -func TestBetterUserLimit(t *testing.T) { +func TestNextLicenseValidityPeriod(t *testing.T) { t.Parallel() - cand := func(limit int64, entitlement codersdk.Entitlement, addon bool) userLimitCandidate { - return userLimitCandidate{limit: limit, entitlement: entitlement, aiGovernanceAddon: addon} - } - entitled := codersdk.EntitlementEntitled - grace := codersdk.EntitlementGracePeriod + t.Run("Apply", func(t *testing.T) { + t.Parallel() - cases := []struct { - name string - a, b userLimitCandidate - countA, countB int64 - want bool - }{ - { - name: "ComplianceBeatsEntitlement", - a: cand(200, grace, false), countA: 150, - b: cand(100, entitled, false), countB: 150, - want: true, - }, - { - name: "ComplianceBeatsHigherLimit", - a: cand(100, entitled, true), countA: 90, - b: cand(200, entitled, false), countB: 250, - want: true, - }, - { - name: "EntitlementBeatsLimitWhenBothCompliant", - a: cand(100, entitled, false), countA: 50, - b: cand(200, grace, false), countB: 50, - want: true, - }, - { - name: "HigherLimitWinsWhenBothCompliantAndEqualEntitlement", - a: cand(200, entitled, false), countA: 50, - b: cand(100, entitled, false), countB: 50, - want: true, - }, - { - name: "HigherLimitWinsWhenBothOver", - a: cand(200, entitled, false), countA: 250, - b: cand(100, entitled, true), countB: 150, - want: true, - }, - { - name: "AddonBreaksExactTies", - a: cand(100, entitled, true), countA: 50, - b: cand(100, entitled, false), countB: 80, - want: true, - }, - { - name: "EqualCandidatesAreNotBetter", - a: cand(100, entitled, false), countA: 50, - b: cand(100, entitled, false), countB: 50, - want: false, - }, - } + testCases := []struct { + name string + + licensePeriods [][2]time.Time + expectedPeriods [][2]time.Time + }{ + { + name: "None", + licensePeriods: [][2]time.Time{}, + expectedPeriods: [][2]time.Time{}, + }, + { + name: "One", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "TwoOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "TwoNonOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "ThreeOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "ThreeNonOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "PeriodContainsAnotherPeriod", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "EndBeforeStart", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Test with all possible permutations of the periods to ensure + // consistency regardless of the order. + ps := permutations(tc.licensePeriods) + for _, p := range ps { + t.Logf("permutation: %v", p) + period := &licenseValidityPeriod{} + for _, times := range p { + t.Logf("applying %v", times) + period.Apply(times[0], times[1]) + } + assert.Equal(t, tc.expectedPeriods, period.merged(), "merged") + } + }) + } + }) +} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, betterUserLimit(tc.a, tc.b, tc.countA, tc.countB)) - if tc.want { - require.False(t, betterUserLimit(tc.b, tc.a, tc.countB, tc.countA), - "strict ordering must not hold both ways") - } - }) +func permutations[T any](arr []T) [][]T { + var res [][]T + var helper func([]T, int) + helper = func(a []T, i int) { + if i == len(a)-1 { + // make a copy before appending + tmp := make([]T, len(a)) + copy(tmp, a) + res = append(res, tmp) + return + } + for j := i; j < len(a); j++ { + a[i], a[j] = a[j], a[i] + helper(a, i+1) + a[i], a[j] = a[j], a[i] // backtrack + } } + helper(arr, 0) + return res } diff --git a/enterprise/coderd/license/userlimit_internal_test.go b/enterprise/coderd/license/userlimit_internal_test.go new file mode 100644 index 00000000000..19ca81b02e8 --- /dev/null +++ b/enterprise/coderd/license/userlimit_internal_test.go @@ -0,0 +1,80 @@ +package license + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestBetterUserLimit(t *testing.T) { + t.Parallel() + + cand := func(limit int64, entitlement codersdk.Entitlement, addon bool) userLimitCandidate { + return userLimitCandidate{limit: limit, entitlement: entitlement, aiGovernanceAddon: addon} + } + entitled := codersdk.EntitlementEntitled + grace := codersdk.EntitlementGracePeriod + + cases := []struct { + name string + a, b userLimitCandidate + countA, countB int64 + want bool + }{ + { + name: "ComplianceBeatsEntitlement", + a: cand(200, grace, false), countA: 150, + b: cand(100, entitled, false), countB: 150, + want: true, + }, + { + name: "ComplianceBeatsHigherLimit", + a: cand(100, entitled, true), countA: 90, + b: cand(200, entitled, false), countB: 250, + want: true, + }, + { + name: "EntitlementBeatsLimitWhenBothCompliant", + a: cand(100, entitled, false), countA: 50, + b: cand(200, grace, false), countB: 50, + want: true, + }, + { + name: "HigherLimitWinsWhenBothCompliantAndEqualEntitlement", + a: cand(200, entitled, false), countA: 50, + b: cand(100, entitled, false), countB: 50, + want: true, + }, + { + name: "HigherLimitWinsWhenBothOver", + a: cand(200, entitled, false), countA: 250, + b: cand(100, entitled, true), countB: 150, + want: true, + }, + { + name: "AddonBreaksExactTies", + a: cand(100, entitled, true), countA: 50, + b: cand(100, entitled, false), countB: 80, + want: true, + }, + { + name: "EqualCandidatesAreNotBetter", + a: cand(100, entitled, false), countA: 50, + b: cand(100, entitled, false), countB: 50, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, betterUserLimit(tc.a, tc.b, tc.countA, tc.countB)) + if tc.want { + require.False(t, betterUserLimit(tc.b, tc.a, tc.countB, tc.countA), + "strict ordering must not hold both ways") + } + }) + } +} From bc000dc3bea7903e89459c9b13f731f0bf2b2d23 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 21 Jul 2026 15:45:09 +0000 Subject: [PATCH 25/41] refactor(enterprise/coderd/license): drop the per-organization workspace-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. --- enterprise/coderd/license/usercount.go | 38 +++++++------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 84bee0ef72b..7bc221190a8 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -77,9 +76,8 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data } // canCreateWorkspace reports whether the RBAC engine authorizes the user -// to create a workspace they own, checked against every organization the -// user is a member of plus the any-organization form that site-wide roles -// satisfy regardless of org membership. +// to create a workspace they own in any organization: via membership +// grants or via a site-wide role that applies regardless of membership. func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { roleNames, err := row.RoleNames() if err != nil { @@ -112,30 +110,14 @@ func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Sto Scope: rbac.ScopeAll, }.WithCachedASTValue() - // Site-wide grants (e.g. the owner role) authorize workspace creation - // in any organization, independent of org membership. This also covers - // users who belong to zero organizations. - if authorizer.Authorize(ctx, subject, policy.ActionCreate, - rbac.ResourceWorkspace.AnyOrganization().WithOwner(subject.ID)) == nil { - return true, nil - } - - seen := make(map[uuid.UUID]struct{}) - for _, role := range roleNames { - orgID := role.OrganizationID - if orgID == uuid.Nil { - continue - } - if _, ok := seen[orgID]; ok { - continue - } - seen[orgID] = struct{}{} - if authorizer.Authorize(ctx, subject, policy.ActionCreate, - rbac.ResourceWorkspace.InOrg(orgID).WithOwner(subject.ID)) == nil { - return true, nil - } - } - return false, nil + // A site-wide grant (e.g. the owner role) authorizes creation in any + // organization, and the any-organization policy form resolves to the + // maximum per-org vote across the subject's memberships, so it also + // subsumes per-organization checks: it allows exactly when some + // InOrg(id) check would. This also covers users who belong to zero + // organizations. + return authorizer.Authorize(ctx, subject, policy.ActionCreate, + rbac.ResourceWorkspace.AnyOrganization().WithOwner(subject.ID)) == nil, nil } // authorizationSignature returns a canonical key for the user's role set. From 000508d56bdc391468ff0ebce2815ca9c114138f Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 22 Jul 2026 14:02:06 +0000 Subject: [PATCH 26/41] refactor(enterprise/coderd/license): extract user_limit best-pair selection 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. --- enterprise/coderd/license/license.go | 176 ++++++++++++++++----------- 1 file changed, 105 insertions(+), 71 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ba9e1dd18fa..fc71f754bdf 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -173,6 +173,103 @@ func betterUserLimit(a, b userLimitCandidate, countA, countB int64) bool { return a.aiGovernanceAddon && !b.aiGovernanceAddon } +// userLimitSelection reports how the enforced user_limit was chosen. +type userLimitSelection struct { + // permissionBased is true when the selected candidate counts + // workspace-capable users rather than all active users. + permissionBased bool + // legacyActiveUserCount is the all-active-users count that applied + // before the capable count overwrote it. Only set when + // permissionBased is true. + legacyActiveUserCount int64 + // addonEntitled is true when at least one addon-carrying candidate is + // fully valid rather than in its grace period. + addonEntitled bool +} + +// selectUserLimit picks the most favorable user_limit candidate and +// applies its terms to the entitlements. Every candidate is evaluated +// against the count its own license's mode implies, so one license's +// limit is never combined with another license's counting mode. A +// candidate satisfied by its count wins over any unsatisfied one. +// +// When an addon candidate is selected, the capable count overwrites +// featureArguments.ActiveUserCount, which the user_limit feature's +// Actual pointer aliases: Feature values copy the pointer, not the +// int64, so every copy of the feature observes the write, as do the +// caller's over-limit warnings. featureArguments must therefore point +// at the caller's copy, and replacing Actual with a fresh allocation +// would break this. +// +// With no candidates the entitlements are left untouched. On a count +// failure the entitlements computation must be aborted. +func selectUserLimit( + ctx context.Context, + entitlements *codersdk.Entitlements, + featureArguments *FeatureArguments, + candidates []userLimitCandidate, +) (userLimitSelection, error) { + var sel userLimitSelection + if len(candidates) == 0 { + return sel, nil + } + + hasAddonCandidate := false + for _, c := range candidates { + if c.aiGovernanceAddon { + hasAddonCandidate = true + if c.entitlement == codersdk.EntitlementEntitled { + sel.addonEntitled = true + } + } + } + + var capableCount int64 + capableCountValid := false + if hasAddonCandidate && featureArguments.WorkspaceCapableUserCountFn != nil { + count, err := featureArguments.WorkspaceCapableUserCountFn(ctx) + if err != nil { + // A failed seat count is deliberately a hard failure rather + // than a recorded entitlement error: continuing with + // ActiveUserCount would silently change what user_limit + // measures. The caller keeps the previous entitlements, so a + // failure yields a stale count rather than a different one. + return sel, xerrors.Errorf("count workspace capable users: %w", err) + } + capableCount = count + capableCountValid = true + } + countFor := func(c userLimitCandidate) int64 { + if c.aiGovernanceAddon && capableCountValid { + return capableCount + } + return featureArguments.ActiveUserCount + } + + best := candidates[0] + for _, c := range candidates[1:] { + if betterUserLimit(c, best, countFor(c), countFor(best)) { + best = c + } + } + + if best.aiGovernanceAddon && capableCountValid { + sel.legacyActiveUserCount = featureArguments.ActiveUserCount + featureArguments.ActiveUserCount = capableCount + sel.permissionBased = true + } + + // AddFeature merged limits and entitlements across licenses without + // pairing them to counting modes; overwrite the merged terms with the + // selected candidate's. Actual keeps aliasing + // featureArguments.ActiveUserCount. + userLimit := entitlements.Features[codersdk.FeatureUserLimit] + userLimit.Limit = &best.limit + userLimit.Entitlement = best.entitlement + entitlements.Features[codersdk.FeatureUserLimit] = userLimit + return sel, nil +} + // LicensesEntitlements returns the entitlements for licenses. Entitlements are // merged from all licenses and the highest entitlement is used for each feature. // Arguments: @@ -471,74 +568,11 @@ func LicensesEntitlements( } } - // Best-pair user_limit selection. Every candidate is evaluated against - // the count its own license's mode implies, so one license's limit is - // never combined with another license's counting mode. A candidate - // satisfied by its count wins over any unsatisfied one. - // - // When an addon candidate is selected, the capable count overwrites - // featureArguments.ActiveUserCount, which the user_limit feature's - // Actual pointer aliases: Feature values copy the pointer, not the - // int64, so every copy of the feature observes the write, as do the - // over-limit warnings below. Replacing Actual with a fresh allocation - // would break this. - permissionBasedUserCount := false - var legacyActiveUserCount int64 - aiGovernanceAddonEntitled := false - if len(userLimitCandidates) > 0 { - hasAddonCandidate := false - for _, c := range userLimitCandidates { - if c.aiGovernanceAddon { - hasAddonCandidate = true - if c.entitlement == codersdk.EntitlementEntitled { - aiGovernanceAddonEntitled = true - } - } - } - - var capableCount int64 - capableCountValid := false - if hasAddonCandidate && featureArguments.WorkspaceCapableUserCountFn != nil { - count, err := featureArguments.WorkspaceCapableUserCountFn(ctx) - if err != nil { - // A failed seat count is deliberately a hard failure rather - // than a recorded entitlement error: continuing with - // ActiveUserCount would silently change what user_limit - // measures. The caller keeps the previous entitlements, so a - // failure yields a stale count rather than a different one. - return entitlements, xerrors.Errorf("count workspace capable users: %w", err) - } - capableCount = count - capableCountValid = true - } - countFor := func(c userLimitCandidate) int64 { - if c.aiGovernanceAddon && capableCountValid { - return capableCount - } - return featureArguments.ActiveUserCount - } - - best := userLimitCandidates[0] - for _, c := range userLimitCandidates[1:] { - if betterUserLimit(c, best, countFor(c), countFor(best)) { - best = c - } - } - - if best.aiGovernanceAddon && capableCountValid { - legacyActiveUserCount = featureArguments.ActiveUserCount - featureArguments.ActiveUserCount = capableCount - permissionBasedUserCount = true - } - - // AddFeature merged limits and entitlements across licenses without - // pairing them to counting modes; overwrite the merged terms with - // the selected candidate's. Actual keeps aliasing - // featureArguments.ActiveUserCount. - userLimit := entitlements.Features[codersdk.FeatureUserLimit] - userLimit.Limit = &best.limit - userLimit.Entitlement = best.entitlement - entitlements.Features[codersdk.FeatureUserLimit] = userLimit + // The user_limit feature's final terms come from best-pair selection + // across the candidates rather than the AddFeature merge. + userLimitSel, err := selectUserLimit(ctx, &entitlements, &featureArguments, userLimitCandidates) + if err != nil { + return entitlements, err } // Now the license specific warnings and errors are added to the entitlements. @@ -639,7 +673,7 @@ func LicensesEntitlements( // workspace-capable users, not all active users; the warning must // name what was counted. userNoun := "active users" - if permissionBasedUserCount { + if userLimitSel.permissionBased { userNoun = "workspace-capable users" } if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit { @@ -654,10 +688,10 @@ func LicensesEntitlements( // The addon exists only on grace-period licenses: warn that // workspace-capable counting stops at the end of the grace period, // at which point every active user counts. - if permissionBasedUserCount && !aiGovernanceAddonEntitled { + if userLimitSel.permissionBased && !userLimitSel.addonEntitled { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your license with the AI Governance addon is expired. When it fully expires, all %d active users will count toward the user limit instead of the %d workspace-capable users.", - legacyActiveUserCount, featureArguments.ActiveUserCount)) + userLimitSel.legacyActiveUserCount, featureArguments.ActiveUserCount)) } if featureArguments.ActiveAISeatCount > 0 { actual := featureArguments.ActiveAISeatCount From 72f799a4a55877ede3f533618e6ff29d0004a68f Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 22 Jul 2026 14:46:45 +0000 Subject: [PATCH 27/41] chore(enterprise/coderd/license): name FeatureUserLimit in comments instead of its wire value --- enterprise/coderd/license/license.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index fc71f754bdf..72a7b8fa890 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -132,8 +132,8 @@ type FeatureArguments struct { // WorkspaceCapableUserCountFn returns the number of active users the // RBAC engine authorizes to create workspaces. It is invoked only when // a valid license carries both the AI Governance addon and a - // user_limit claim; the result then applies to that license's - // user_limit candidate, and replaces ActiveUserCount when such a + // FeatureUserLimit claim; the result then applies to that license's + // FeatureUserLimit candidate, and replaces ActiveUserCount when such a // candidate is selected for enforcement. May be nil, in which case // ActiveUserCount is always used. WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn @@ -143,7 +143,7 @@ type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) -// userLimitCandidate is one license's user_limit terms: its seat limit, +// userLimitCandidate is one license's FeatureUserLimit terms: its seat limit, // its entitlement, and the counting mode implied by whether the license // carries the AI Governance addon. type userLimitCandidate struct { @@ -173,7 +173,7 @@ func betterUserLimit(a, b userLimitCandidate, countA, countB int64) bool { return a.aiGovernanceAddon && !b.aiGovernanceAddon } -// userLimitSelection reports how the enforced user_limit was chosen. +// userLimitSelection reports how the enforced FeatureUserLimit was chosen. type userLimitSelection struct { // permissionBased is true when the selected candidate counts // workspace-capable users rather than all active users. @@ -187,14 +187,14 @@ type userLimitSelection struct { addonEntitled bool } -// selectUserLimit picks the most favorable user_limit candidate and +// selectUserLimit picks the most favorable FeatureUserLimit candidate and // applies its terms to the entitlements. Every candidate is evaluated // against the count its own license's mode implies, so one license's // limit is never combined with another license's counting mode. A // candidate satisfied by its count wins over any unsatisfied one. // // When an addon candidate is selected, the capable count overwrites -// featureArguments.ActiveUserCount, which the user_limit feature's +// featureArguments.ActiveUserCount, which the FeatureUserLimit feature's // Actual pointer aliases: Feature values copy the pointer, not the // int64, so every copy of the feature observes the write, as do the // caller's over-limit warnings. featureArguments must therefore point @@ -231,7 +231,7 @@ func selectUserLimit( if err != nil { // A failed seat count is deliberately a hard failure rather // than a recorded entitlement error: continuing with - // ActiveUserCount would silently change what user_limit + // ActiveUserCount would silently change what FeatureUserLimit // measures. The caller keeps the previous entitlements, so a // failure yields a stale count rather than a different one. return sel, xerrors.Errorf("count workspace capable users: %w", err) @@ -294,7 +294,7 @@ func LicensesEntitlements( // suppress the soft warning for AI Bridge GA. hasExplicitAIBridgeEntitlement := false - // Each valid license's user_limit claim forms a candidate pairing of + // Each valid license's FeatureUserLimit claim forms a candidate pairing of // seat limit and counting mode: licenses carrying the AI Governance // addon count workspace-capable users, others count all active users. // The most favorable candidate is selected once all licenses are @@ -568,7 +568,7 @@ func LicensesEntitlements( } } - // The user_limit feature's final terms come from best-pair selection + // The FeatureUserLimit feature's final terms come from best-pair selection // across the candidates rather than the AddFeature merge. userLimitSel, err := selectUserLimit(ctx, &entitlements, &featureArguments, userLimitCandidates) if err != nil { From 3a1ebae251c6f00419aa0c584bf0a5c740dadefc Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 22 Jul 2026 14:50:33 +0000 Subject: [PATCH 28/41] chore(enterprise/coderd/license): name the concrete counting modes in comments --- enterprise/coderd/license/license.go | 11 +++++++---- enterprise/coderd/license/usercount.go | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 72a7b8fa890..c00d6d2fb4c 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -145,7 +145,8 @@ type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) // userLimitCandidate is one license's FeatureUserLimit terms: its seat limit, // its entitlement, and the counting mode implied by whether the license -// carries the AI Governance addon. +// carries the AI Governance addon (permission-based counting of +// workspace-capable users vs. counting all active users). type userLimitCandidate struct { limit int64 entitlement codersdk.Entitlement @@ -189,9 +190,11 @@ type userLimitSelection struct { // selectUserLimit picks the most favorable FeatureUserLimit candidate and // applies its terms to the entitlements. Every candidate is evaluated -// against the count its own license's mode implies, so one license's -// limit is never combined with another license's counting mode. A -// candidate satisfied by its count wins over any unsatisfied one. +// against the count its own license's mode implies (the workspace-capable +// count for permission-based candidates, the active user count +// otherwise), so one license's limit is never combined with another +// license's counting mode. A candidate satisfied by its count wins over +// any unsatisfied one. // // When an addon candidate is selected, the capable count overwrites // featureArguments.ActiveUserCount, which the FeatureUserLimit feature's diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 7bc221190a8..c78859f3cde 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -65,7 +65,8 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data } // Emitted only when permission-based counting runs, so the line's - // presence identifies the counting mode. + // presence identifies the counting mode (permission-based vs. all + // active users). logger.Info(ctx, "counted workspace-capable users for license seats", slog.F("workspace_capable_users", count), slog.F("active_users", len(rows)), From 62d5153b45d2757044e034aadcf269c23d57d081 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 22 Jul 2026 23:17:58 +0000 Subject: [PATCH 29/41] refactor(enterprise/coderd/license): make the user counting mode explicit 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. --- enterprise/coderd/license/license.go | 58 +++++++++++++++------ enterprise/coderd/license/usercount_test.go | 46 ++++++++++++---- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index c00d6d2fb4c..f7c02f448cf 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -58,17 +58,20 @@ func Entitlements( } // Permission-based licensing counts only users the RBAC engine - // authorizes to create workspaces. The count is resolved lazily by - // LicensesEntitlements, and only when a valid license carries the AI - // Governance addon; deployments without the addon always use the - // plain active user count. - var workspaceCapableUserCountFn WorkspaceCapableUserCountFn + // authorizes to create workspaces. The counting function is always + // provided; UserCountingMode decides whether LicensesEntitlements + // uses it, and only for licenses carrying the AI Governance addon. + countingMode := UserCountingModeActive if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { - workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { - ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) - defer cancel() - return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) + countingMode = UserCountingModePermissionBased + } + workspaceCapableUserCountFn := func(ctx context.Context) (int64, error) { + if authorizer == nil { + return 0, xerrors.New("dev error: an authorizer is required to count workspace-capable users") } + ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) + defer cancel() + return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) } // nolint:gocritic // Getting active AI seat count is a system function. @@ -94,6 +97,7 @@ func Entitlements( ReplicaCount: replicaCount, ExternalAuthCount: externalAuthCount, ExternalTemplateCount: int64(len(externalTemplates)), + UserCountingMode: countingMode, WorkspaceCapableUserCountFn: workspaceCapableUserCountFn, ManagedAgentCountFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { // This is not super accurate, as the start and end times will be @@ -129,16 +133,35 @@ type FeatureArguments struct { // state of the world, but a count between two points in time determined by // the licenses. ManagedAgentCountFn ManagedAgentCountFn + // UserCountingMode selects the count that FeatureUserLimit candidates + // from AI Governance addon licenses are evaluated against. Under + // UserCountingModePermissionBased they use WorkspaceCapableUserCountFn's + // count; under UserCountingModeActive (the zero value) every candidate + // uses ActiveUserCount. + UserCountingMode UserCountingMode // WorkspaceCapableUserCountFn returns the number of active users the - // RBAC engine authorizes to create workspaces. It is invoked only when - // a valid license carries both the AI Governance addon and a - // FeatureUserLimit claim; the result then applies to that license's - // FeatureUserLimit candidate, and replaces ActiveUserCount when such a - // candidate is selected for enforcement. May be nil, in which case - // ActiveUserCount is always used. + // RBAC engine authorizes to create workspaces. It is invoked only + // under UserCountingModePermissionBased, and only when a valid + // license carries both the AI Governance addon and a FeatureUserLimit + // claim; the result then applies to that license's FeatureUserLimit + // candidate, and replaces ActiveUserCount when such a candidate is + // selected for enforcement. WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn } +// UserCountingMode selects how license seats are counted for +// FeatureUserLimit candidates from AI Governance addon licenses. +type UserCountingMode string + +const ( + // UserCountingModeActive evaluates every FeatureUserLimit candidate + // against the active user count. + UserCountingModeActive UserCountingMode = "" + // UserCountingModePermissionBased evaluates addon-carrying candidates + // against the workspace-capable user count. + UserCountingModePermissionBased UserCountingMode = "permission_based" +) + type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error) type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) @@ -229,7 +252,10 @@ func selectUserLimit( var capableCount int64 capableCountValid := false - if hasAddonCandidate && featureArguments.WorkspaceCapableUserCountFn != nil { + if hasAddonCandidate && featureArguments.UserCountingMode == UserCountingModePermissionBased { + if featureArguments.WorkspaceCapableUserCountFn == nil { + return sel, xerrors.New("dev error: workspace-capable user count function is not set") + } count, err := featureArguments.WorkspaceCapableUserCountFn(ctx) if err != nil { // A failed seat count is deliberately a hard failure rather diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 103eb9e9e1d..bda50e81ac2 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -336,7 +336,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { Features: license.Features{codersdk.FeatureUserLimit: 100}, }).Valid(now))} entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { t.Fatal("count fn must not be called without the addon") return 0, nil @@ -355,7 +356,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }).Valid(now) opts.Addons = append(opts.Addons, codersdk.AddonAIGovernance) entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{dbLicense(*opts)}, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { t.Fatal("count fn must not be called when addon dependencies are unmet") return 0, nil @@ -366,9 +368,26 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) }) - t.Run("AddonUsesFn", func(t *testing.T) { + t.Run("ActiveModeIgnoresFn", func(t *testing.T) { + // UserCountingMode is authoritative: with the mode left at its + // active-users zero value, the counting function must not be + // called even though it is set and the addon is present. entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + t.Fatal("count fn must not be called in active counting mode") + return 0, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(7), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + }) + + t.Run("AddonUsesFn", func(t *testing.T) { + entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 3, nil }, @@ -400,7 +419,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { } run := func(t *testing.T, activeUsers, capableUsers int64) codersdk.Entitlements { entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: activeUsers, + ActiveUserCount: activeUsers, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return capableUsers, nil }, @@ -454,7 +474,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }).GracePeriod(now).AIGovernanceAddon(10)), } entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 250, + ActiveUserCount: 250, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 90, nil }, @@ -480,7 +501,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { addonLicense(), } entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 80, + ActiveUserCount: 80, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 30, nil }, @@ -496,7 +518,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { // count it was compared against, and say so, rather than // claiming that many "active users" exist. entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 150, nil }, @@ -514,7 +537,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { Features: license.Features{codersdk.FeatureUserLimit: 100}, }).GracePeriod(now).AIGovernanceAddon(10))} entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 3, nil }, @@ -535,7 +559,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { // the previous entitlements rather than seeing a silently // different count. _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 0, xerrors.New("boom") }, @@ -546,7 +571,8 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("ContextCanceledBails", func(t *testing.T) { _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 0, context.Canceled }, From f71d9259abc30e583a2ec759d48578996ae9e8ef Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 14:39:27 +0000 Subject: [PATCH 30/41] test(enterprise/coderd/license): cover count error paths and remaining 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. --- coderd/rbac/rolestore/rolestore_test.go | 27 +++++++ enterprise/coderd/license/usercount_test.go | 89 +++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 46b394e235a..78a2c3de626 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/xerrors" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" @@ -76,6 +77,32 @@ func TestPrefetchCustomRoles(t *testing.T) { require.Equal(t, "prefetched", roles[0].Identifier.Name) } +func TestPrefetchCustomRolesErrors(t *testing.T) { + t.Parallel() + + t.Run("FetchError", func(t *testing.T) { + t.Parallel() + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().CustomRoles(gomock.Any(), gomock.Any()).Return(nil, xerrors.New("boom")) + + _, err := rolestore.PrefetchCustomRoles(context.Background(), mDB) + require.ErrorContains(t, err, "fetch custom roles") + }) + + t.Run("ConvertError", func(t *testing.T) { + t.Parallel() + // Org permissions without an organization ID cannot be converted. + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().CustomRoles(gomock.Any(), gomock.Any()).Return([]database.CustomRole{{ + Name: "broken", + OrgPermissions: []database.CustomRolePermission{{ResourceType: "workspace", Action: "create"}}, + }}, nil) + + _, err := rolestore.PrefetchCustomRoles(context.Background(), mDB) + require.ErrorContains(t, err, `convert db role "broken"`) + }) +} + func TestReconcileSystemRole(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index bda50e81ac2..1548e8fed6f 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -8,10 +8,12 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" @@ -511,6 +513,43 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.Equal(t, int64(30), *entitlements.Features[codersdk.FeatureUserLimit].Actual) require.Equal(t, int64(100), *entitlements.Features[codersdk.FeatureUserLimit].Limit) }) + + t.Run("TwoAddonCandidates", func(t *testing.T) { + // Two addon licenses: the entitled higher-limit pair fits + // the capable count and wins over the grace pair, and its + // presence suppresses the revert warning. + licenses := []database.License{ + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 100}, + }).GracePeriod(now).AIGovernanceAddon(10)), + dbLicense(*(&coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureUserLimit: 300}, + }).Valid(now).AIGovernanceAddon(10)), + } + entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 500, + UserCountingMode: license.UserCountingModePermissionBased, + WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { + return 150, nil + }, + }) + require.NoError(t, err) + require.Equal(t, int64(150), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + require.Equal(t, int64(300), *entitlements.Features[codersdk.FeatureUserLimit].Limit) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) + for _, warning := range entitlements.Warnings { + require.NotContains(t, warning, "fully expires") + require.NotContains(t, warning, "users but") + } + }) + }) + + t.Run("ModeWithoutFnIsDevError", func(t *testing.T) { + _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ + ActiveUserCount: 7, + UserCountingMode: license.UserCountingModePermissionBased, + }) + require.ErrorContains(t, err, "dev error") }) t.Run("OverLimitWarnsWithCapableCount", func(t *testing.T) { @@ -581,3 +620,53 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }) }) } + +// TestCountWorkspaceCapableUsersErrors covers the count's database +// failure paths, which abort the count rather than skewing it. +// +// Reads the builtin role registry that sibling tests reload, so it must +// run serially. +// +//nolint:paralleltest +func TestCountWorkspaceCapableUsersErrors(t *testing.T) { + ctx := context.Background() + authorizer := rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + + prefetchParams := database.CustomRolesParams{IncludeSystemRoles: true} + + t.Run("PrefetchError", func(t *testing.T) { + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return(nil, xerrors.New("boom")) + + _, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer) + require.ErrorContains(t, err, "prefetch custom roles") + }) + + t.Run("RolesQueryError", func(t *testing.T) { + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return([]database.CustomRole{}, nil) + mDB.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return(nil, xerrors.New("boom")) + + _, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer) + require.ErrorContains(t, err, "get active users authorization roles") + }) + + t.Run("ExpandLookupError", func(t *testing.T) { + // A custom role that was not prefetched (deleted, or created + // mid-count) is looked up individually; a database failure there + // aborts the count. + mDB := dbmock.NewMockStore(gomock.NewController(t)) + userID := uuid.New() + orgID := uuid.New() + mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return([]database.CustomRole{}, nil) + mDB.EXPECT().GetActiveUsersAuthorizationRoles(gomock.Any()).Return([]database.GetActiveUsersAuthorizationRolesRow{{ + ID: userID, + Roles: []string{"member", "dangling-role:" + orgID.String()}, + }}, nil) + mDB.EXPECT().CustomRoles(gomock.Any(), gomock.Not(prefetchParams)).Return(nil, xerrors.New("boom")) + + _, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, authorizer) + require.ErrorContains(t, err, "evaluate workspace-create for user "+userID.String()) + require.ErrorContains(t, err, "expand roles") + }) +} From c0315f3265a95579f073f04ffa9b7d750ebae37a Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 23 Jul 2026 17:50:26 +0000 Subject: [PATCH 31/41] test(enterprise/coderd/license): assert AI Governance seats are unaffected by permission-based counting --- enterprise/coderd/license/usercount_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 1548e8fed6f..5c231fa25ac 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -388,8 +388,9 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("AddonUsesFn", func(t *testing.T) { entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ - ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + ActiveUserCount: 7, + ActiveAISeatCount: 5, + UserCountingMode: license.UserCountingModePermissionBased, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 3, nil }, @@ -397,6 +398,11 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { require.NoError(t, err) require.Empty(t, entitlements.Errors) require.Equal(t, int64(3), *entitlements.Features[codersdk.FeatureUserLimit].Actual) + // Permission-based counting applies to workspace seats only: + // AI Governance seats keep their own count and limit. + aiSeats := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] + require.Equal(t, int64(5), *aiSeats.Actual) + require.Equal(t, int64(10), *aiSeats.Limit) // Under the limit: no user-limit warning, even though the // legacy active user count would also have been under it. for _, warning := range entitlements.Warnings { From e4053510c466d189784a5e96a7198891e7ae4dfe Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Fri, 24 Jul 2026 20:23:55 +0000 Subject: [PATCH 32/41] refactor(enterprise/coderd/license): simplify candidate count resolution - 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. --- enterprise/coderd/license/license.go | 72 ++++++++++--------- enterprise/coderd/license/usercount.go | 10 +-- .../coderd/license/userlimit_internal_test.go | 46 ++++++------ 3 files changed, 69 insertions(+), 59 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index f7c02f448cf..1af93cfb22a 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -58,20 +58,17 @@ func Entitlements( } // Permission-based licensing counts only users the RBAC engine - // authorizes to create workspaces. The counting function is always - // provided; UserCountingMode decides whether LicensesEntitlements - // uses it, and only for licenses carrying the AI Governance addon. + // authorizes to create workspaces. The counting function is provided + // exactly when the mode selects it. countingMode := UserCountingModeActive + var workspaceCapableUserCountFn WorkspaceCapableUserCountFn if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { countingMode = UserCountingModePermissionBased - } - workspaceCapableUserCountFn := func(ctx context.Context) (int64, error) { - if authorizer == nil { - return 0, xerrors.New("dev error: an authorizer is required to count workspace-capable users") + workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) + defer cancel() + return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) } - ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) - defer cancel() - return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) } // nolint:gocritic // Getting active AI seat count is a system function. @@ -145,7 +142,9 @@ type FeatureArguments struct { // license carries both the AI Governance addon and a FeatureUserLimit // claim; the result then applies to that license's FeatureUserLimit // candidate, and replaces ActiveUserCount when such a candidate is - // selected for enforcement. + // selected for enforcement. May be nil under UserCountingModeActive; + // leaving it nil when the permission-based mode would invoke it is a + // dev error. WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn } @@ -176,15 +175,22 @@ type userLimitCandidate struct { aiGovernanceAddon bool } -// betterUserLimit reports whether candidate a is more favorable than b, -// where countA and countB are the user counts each candidate's mode -// implies. Ordering mirrors Feature.Compare: a candidate whose count is -// within its limit beats one whose count is not, then higher entitlement, -// then higher limit; the addon mode breaks remaining ties since its -// count is never larger than the active user count. -func betterUserLimit(a, b userLimitCandidate, countA, countB int64) bool { - compliantA := countA <= a.limit - compliantB := countB <= b.limit +// resolvedCandidate pairs a candidate with the count its counting mode +// implies: the workspace-capable count for addon candidates when +// permission-based counting is active, the active user count otherwise. +type resolvedCandidate struct { + userLimitCandidate + count int64 +} + +// betterUserLimit reports whether candidate a is more favorable than b. +// Ordering mirrors Feature.Compare: a candidate whose count is within its +// limit beats one whose count is not, then higher entitlement, then +// higher limit; the addon mode breaks remaining ties since its count is +// never larger than the active user count. +func betterUserLimit(a, b resolvedCandidate) bool { + compliantA := a.count <= a.limit + compliantB := b.count <= b.limit if compliantA != compliantB { return compliantA } @@ -250,8 +256,7 @@ func selectUserLimit( } } - var capableCount int64 - capableCountValid := false + var capableCount *int64 if hasAddonCandidate && featureArguments.UserCountingMode == UserCountingModePermissionBased { if featureArguments.WorkspaceCapableUserCountFn == nil { return sel, xerrors.New("dev error: workspace-capable user count function is not set") @@ -265,26 +270,27 @@ func selectUserLimit( // failure yields a stale count rather than a different one. return sel, xerrors.Errorf("count workspace capable users: %w", err) } - capableCount = count - capableCountValid = true + capableCount = &count } - countFor := func(c userLimitCandidate) int64 { - if c.aiGovernanceAddon && capableCountValid { - return capableCount + + resolved := make([]resolvedCandidate, len(candidates)) + for i, c := range candidates { + resolved[i] = resolvedCandidate{userLimitCandidate: c, count: featureArguments.ActiveUserCount} + if c.aiGovernanceAddon && capableCount != nil { + resolved[i].count = *capableCount } - return featureArguments.ActiveUserCount } - best := candidates[0] - for _, c := range candidates[1:] { - if betterUserLimit(c, best, countFor(c), countFor(best)) { + best := resolved[0] + for _, c := range resolved[1:] { + if betterUserLimit(c, best) { best = c } } - if best.aiGovernanceAddon && capableCountValid { + if best.aiGovernanceAddon && capableCount != nil { sel.legacyActiveUserCount = featureArguments.ActiveUserCount - featureArguments.ActiveUserCount = capableCount + featureArguments.ActiveUserCount = best.count sel.permissionBased = true } diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index c78859f3cde..7bc5ca329f4 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -2,7 +2,7 @@ package license import ( "context" - "sort" + "slices" "strings" "time" @@ -122,11 +122,13 @@ func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Sto } // authorizationSignature returns a canonical key for the user's role set. -// Two users with equal signatures are interchangeable for -// workspace-create evaluation. +// Roles are sorted and deduplicated, so equivalent sets share a key. Two +// users with equal signatures are interchangeable for workspace-create +// evaluation. func authorizationSignature(row database.GetActiveUsersAuthorizationRolesRow) string { roles := make([]string, len(row.Roles)) copy(roles, row.Roles) - sort.Strings(roles) + slices.Sort(roles) + roles = slices.Compact(roles) return strings.Join(roles, "\x00") } diff --git a/enterprise/coderd/license/userlimit_internal_test.go b/enterprise/coderd/license/userlimit_internal_test.go index 19ca81b02e8..fe0ca14628f 100644 --- a/enterprise/coderd/license/userlimit_internal_test.go +++ b/enterprise/coderd/license/userlimit_internal_test.go @@ -11,58 +11,60 @@ import ( func TestBetterUserLimit(t *testing.T) { t.Parallel() - cand := func(limit int64, entitlement codersdk.Entitlement, addon bool) userLimitCandidate { - return userLimitCandidate{limit: limit, entitlement: entitlement, aiGovernanceAddon: addon} + cand := func(limit, count int64, entitlement codersdk.Entitlement, addon bool) resolvedCandidate { + return resolvedCandidate{ + userLimitCandidate: userLimitCandidate{limit: limit, entitlement: entitlement, aiGovernanceAddon: addon}, + count: count, + } } entitled := codersdk.EntitlementEntitled grace := codersdk.EntitlementGracePeriod cases := []struct { - name string - a, b userLimitCandidate - countA, countB int64 - want bool + name string + a, b resolvedCandidate + want bool }{ { name: "ComplianceBeatsEntitlement", - a: cand(200, grace, false), countA: 150, - b: cand(100, entitled, false), countB: 150, + a: cand(200, 150, grace, false), + b: cand(100, 150, entitled, false), want: true, }, { name: "ComplianceBeatsHigherLimit", - a: cand(100, entitled, true), countA: 90, - b: cand(200, entitled, false), countB: 250, + a: cand(100, 90, entitled, true), + b: cand(200, 250, entitled, false), want: true, }, { name: "EntitlementBeatsLimitWhenBothCompliant", - a: cand(100, entitled, false), countA: 50, - b: cand(200, grace, false), countB: 50, + a: cand(100, 50, entitled, false), + b: cand(200, 50, grace, false), want: true, }, { name: "HigherLimitWinsWhenBothCompliantAndEqualEntitlement", - a: cand(200, entitled, false), countA: 50, - b: cand(100, entitled, false), countB: 50, + a: cand(200, 50, entitled, false), + b: cand(100, 50, entitled, false), want: true, }, { name: "HigherLimitWinsWhenBothOver", - a: cand(200, entitled, false), countA: 250, - b: cand(100, entitled, true), countB: 150, + a: cand(200, 250, entitled, false), + b: cand(100, 150, entitled, true), want: true, }, { name: "AddonBreaksExactTies", - a: cand(100, entitled, true), countA: 50, - b: cand(100, entitled, false), countB: 80, + a: cand(100, 50, entitled, true), + b: cand(100, 80, entitled, false), want: true, }, { name: "EqualCandidatesAreNotBetter", - a: cand(100, entitled, false), countA: 50, - b: cand(100, entitled, false), countB: 50, + a: cand(100, 50, entitled, false), + b: cand(100, 50, entitled, false), want: false, }, } @@ -70,9 +72,9 @@ func TestBetterUserLimit(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - require.Equal(t, tc.want, betterUserLimit(tc.a, tc.b, tc.countA, tc.countB)) + require.Equal(t, tc.want, betterUserLimit(tc.a, tc.b)) if tc.want { - require.False(t, betterUserLimit(tc.b, tc.a, tc.countB, tc.countA), + require.False(t, betterUserLimit(tc.b, tc.a), "strict ordering must not hold both ways") } }) From 0c17122e60053f723c9708aade7b13b103536bc6 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Fri, 24 Jul 2026 21:16:44 +0000 Subject: [PATCH 33/41] refactor: rename permission-based licensing to workspace-capable licensing 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. --- coderd/apidoc/docs.go | 6 +-- coderd/apidoc/swagger.json | 6 +-- codersdk/deployment.go | 32 +++++++------- docs/reference/api/schemas.md | 6 +-- enterprise/coderd/license/license.go | 44 +++++++++---------- enterprise/coderd/license/usercount.go | 4 +- .../coderd/license/usercount_bench_test.go | 2 +- enterprise/coderd/license/usercount_test.go | 34 +++++++------- site/src/api/typesGenerated.ts | 4 +- 9 files changed, 69 insertions(+), 69 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index af83a9fb9f3..1f0454cabdd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20125,7 +20125,7 @@ const docTemplate = `{ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", - "permission-based-licensing", + "workspace-capable-licensing", "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" @@ -20141,8 +20141,8 @@ const docTemplate = `{ "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", - "ExperimentPermissionBasedLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", + "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-descriptions": [ @@ -20170,7 +20170,7 @@ const docTemplate = `{ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", - "ExperimentPermissionBasedLicensing", + "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1ef1412b84a..adc9f99cafb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18277,7 +18277,7 @@ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", - "permission-based-licensing", + "workspace-capable-licensing", "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" @@ -18293,8 +18293,8 @@ "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", - "ExperimentPermissionBasedLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", + "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-descriptions": [ @@ -18322,7 +18322,7 @@ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", - "ExperimentPermissionBasedLicensing", + "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ce5e594bc9c..4fbc7cc388e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5250,19 +5250,19 @@ type Experiment string const ( // Add new experiments here! - ExperimentExample Experiment = "example" // This isn't used for anything. - ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. - ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. - ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. - ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. - ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. - ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. - ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. - ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. - ExperimentPermissionBasedLicensing Experiment = "permission-based-licensing" // Counts only users holding the workspace-create permission toward the license seat limit. - ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. - ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. - ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. + ExperimentExample Experiment = "example" // This isn't used for anything. + ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. + ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. + ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. + ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. + ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. + ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. + ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. + ExperimentWorkspaceCapableLicensing Experiment = "workspace-capable-licensing" // Counts only users holding the workspace-create permission toward the license seat limit. + ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. + ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. + ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. ) func (e Experiment) DisplayName() string { @@ -5285,8 +5285,8 @@ func (e Experiment) DisplayName() string { return "NATS Pubsub" case ExperimentMinimumImplicitMember: return "Gateway Accounts (minimum implicit member)" - case ExperimentPermissionBasedLicensing: - return "Permission-Based Licensing" + case ExperimentWorkspaceCapableLicensing: + return "Workspace-Capable Licensing" case ExperimentAIGatewayCostControl: return "AI Gateway Cost Control" case ExperimentChatAdvisor: @@ -5312,7 +5312,7 @@ var ExperimentsKnown = Experiments{ ExperimentNATSPubsub, ExperimentWorkspaceBuildUpdates, ExperimentMinimumImplicitMember, - ExperimentPermissionBasedLicensing, + ExperimentWorkspaceCapableLicensing, ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 2d1fd49f93f..8c61cab1468 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7242,9 +7242,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `permission-based-licensing`, `workspace-build-updates`, `workspace-usage` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 1af93cfb22a..43f720b3ccc 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -57,13 +57,13 @@ func Entitlements( return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err) } - // Permission-based licensing counts only users the RBAC engine + // Workspace-capable licensing counts only users the RBAC engine // authorizes to create workspaces. The counting function is provided // exactly when the mode selects it. countingMode := UserCountingModeActive var workspaceCapableUserCountFn WorkspaceCapableUserCountFn - if experiments.Enabled(codersdk.ExperimentPermissionBasedLicensing) && authorizer != nil { - countingMode = UserCountingModePermissionBased + if experiments.Enabled(codersdk.ExperimentWorkspaceCapableLicensing) && authorizer != nil { + countingMode = UserCountingModeWorkspaceCapable workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) defer cancel() @@ -132,18 +132,18 @@ type FeatureArguments struct { ManagedAgentCountFn ManagedAgentCountFn // UserCountingMode selects the count that FeatureUserLimit candidates // from AI Governance addon licenses are evaluated against. Under - // UserCountingModePermissionBased they use WorkspaceCapableUserCountFn's - // count; under UserCountingModeActive (the zero value) every candidate - // uses ActiveUserCount. + // UserCountingModeWorkspaceCapable they use WorkspaceCapableUserCountFn's + // count; under any other value, including the zero value, every + // candidate uses ActiveUserCount. UserCountingMode UserCountingMode // WorkspaceCapableUserCountFn returns the number of active users the // RBAC engine authorizes to create workspaces. It is invoked only - // under UserCountingModePermissionBased, and only when a valid + // under UserCountingModeWorkspaceCapable, and only when a valid // license carries both the AI Governance addon and a FeatureUserLimit // claim; the result then applies to that license's FeatureUserLimit // candidate, and replaces ActiveUserCount when such a candidate is // selected for enforcement. May be nil under UserCountingModeActive; - // leaving it nil when the permission-based mode would invoke it is a + // leaving it nil when the workspace-capable mode would invoke it is a // dev error. WorkspaceCapableUserCountFn WorkspaceCapableUserCountFn } @@ -155,10 +155,10 @@ type UserCountingMode string const ( // UserCountingModeActive evaluates every FeatureUserLimit candidate // against the active user count. - UserCountingModeActive UserCountingMode = "" - // UserCountingModePermissionBased evaluates addon-carrying candidates + UserCountingModeActive UserCountingMode = "active_users" + // UserCountingModeWorkspaceCapable evaluates addon-carrying candidates // against the workspace-capable user count. - UserCountingModePermissionBased UserCountingMode = "permission_based" + UserCountingModeWorkspaceCapable UserCountingMode = "workspace_capable_users" ) type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error) @@ -167,7 +167,7 @@ type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error) // userLimitCandidate is one license's FeatureUserLimit terms: its seat limit, // its entitlement, and the counting mode implied by whether the license -// carries the AI Governance addon (permission-based counting of +// carries the AI Governance addon (workspace-capable counting of // workspace-capable users vs. counting all active users). type userLimitCandidate struct { limit int64 @@ -177,7 +177,7 @@ type userLimitCandidate struct { // resolvedCandidate pairs a candidate with the count its counting mode // implies: the workspace-capable count for addon candidates when -// permission-based counting is active, the active user count otherwise. +// workspace-capable counting is active, the active user count otherwise. type resolvedCandidate struct { userLimitCandidate count int64 @@ -205,12 +205,12 @@ func betterUserLimit(a, b resolvedCandidate) bool { // userLimitSelection reports how the enforced FeatureUserLimit was chosen. type userLimitSelection struct { - // permissionBased is true when the selected candidate counts + // workspaceCapable is true when the selected candidate counts // workspace-capable users rather than all active users. - permissionBased bool + workspaceCapable bool // legacyActiveUserCount is the all-active-users count that applied // before the capable count overwrote it. Only set when - // permissionBased is true. + // workspaceCapable is true. legacyActiveUserCount int64 // addonEntitled is true when at least one addon-carrying candidate is // fully valid rather than in its grace period. @@ -220,7 +220,7 @@ type userLimitSelection struct { // selectUserLimit picks the most favorable FeatureUserLimit candidate and // applies its terms to the entitlements. Every candidate is evaluated // against the count its own license's mode implies (the workspace-capable -// count for permission-based candidates, the active user count +// count for workspace-capable candidates, the active user count // otherwise), so one license's limit is never combined with another // license's counting mode. A candidate satisfied by its count wins over // any unsatisfied one. @@ -257,7 +257,7 @@ func selectUserLimit( } var capableCount *int64 - if hasAddonCandidate && featureArguments.UserCountingMode == UserCountingModePermissionBased { + if hasAddonCandidate && featureArguments.UserCountingMode == UserCountingModeWorkspaceCapable { if featureArguments.WorkspaceCapableUserCountFn == nil { return sel, xerrors.New("dev error: workspace-capable user count function is not set") } @@ -291,7 +291,7 @@ func selectUserLimit( if best.aiGovernanceAddon && capableCount != nil { sel.legacyActiveUserCount = featureArguments.ActiveUserCount featureArguments.ActiveUserCount = best.count - sel.permissionBased = true + sel.workspaceCapable = true } // AddFeature merged limits and entitlements across licenses without @@ -704,11 +704,11 @@ func LicensesEntitlements( if entitlements.HasLicense { userLimit := entitlements.Features[codersdk.FeatureUserLimit] - // With permission-based counting, ActiveUserCount holds only + // With workspace-capable counting, ActiveUserCount holds only // workspace-capable users, not all active users; the warning must // name what was counted. userNoun := "active users" - if userLimitSel.permissionBased { + if userLimitSel.workspaceCapable { userNoun = "workspace-capable users" } if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit { @@ -723,7 +723,7 @@ func LicensesEntitlements( // The addon exists only on grace-period licenses: warn that // workspace-capable counting stops at the end of the grace period, // at which point every active user counts. - if userLimitSel.permissionBased && !userLimitSel.addonEntitled { + if userLimitSel.workspaceCapable && !userLimitSel.addonEntitled { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your license with the AI Governance addon is expired. When it fully expires, all %d active users will count toward the user limit instead of the %d workspace-capable users.", userLimitSel.legacyActiveUserCount, featureArguments.ActiveUserCount)) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 7bc5ca329f4..6c2a99ea075 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -64,8 +64,8 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data } } - // Emitted only when permission-based counting runs, so the line's - // presence identifies the counting mode (permission-based vs. all + // Emitted only when workspace-capable counting runs, so the line's + // presence identifies the counting mode (workspace-capable vs. all // active users). logger.Info(ctx, "counted workspace-capable users for license seats", slog.F("workspace_capable_users", count), diff --git a/enterprise/coderd/license/usercount_bench_test.go b/enterprise/coderd/license/usercount_bench_test.go index aa065dfac76..4e635d04ef0 100644 --- a/enterprise/coderd/license/usercount_bench_test.go +++ b/enterprise/coderd/license/usercount_bench_test.go @@ -18,7 +18,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" ) -// BenchmarkCountWorkspaceCapableUsers measures how permission-based seat +// BenchmarkCountWorkspaceCapableUsers measures how workspace-capable seat // counting scales along its two cost axes: the number of eligible active // users (row fetch and per-row signature work) and the number of unique // role sets (role expansion and rego evaluation, one per set). diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 5c231fa25ac..3c58c03c10a 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -24,7 +24,7 @@ import ( "github.com/coder/coder/v2/testutil" ) -// TestCountWorkspaceCapableUsers verifies permission-based license seat +// TestCountWorkspaceCapableUsers verifies workspace-capable license seat // counting: only users the RBAC engine authorizes to create workspaces // consume seats, so members without workspace-create ("gateway accounts") // are excluded. @@ -64,7 +64,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("ElevationBundledParity", func(t *testing.T) { // MinimumImplicitMember off (default): organization-member bundles // the workspace-ops elevation, so every active org member counts - // and the permission-based count matches the legacy count except + // and the workspace-capable count matches the legacy count except // for zero-org plain members. rbac.ReloadBuiltinRoles(nil) t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) }) @@ -81,7 +81,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { owner := activeUser(t, db, database.User{RBACRoles: []string{rbac.RoleOwner().Name}}) member(t, db, org.ID, owner) - // Counts under legacy, not under permission-based: no org, no + // Counts under legacy, not under workspace-capable counting: no org, no // workspace-create anywhere. activeUser(t, db, database.User{}) @@ -267,7 +267,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { member(t, db, org.ID, wsUser, rbac.RoleOrgWorkspaceAccess()) enablements := map[codersdk.FeatureName]bool{} - experimentOn := codersdk.Experiments{codersdk.ExperimentPermissionBasedLicensing} + experimentOn := codersdk.Experiments{codersdk.ExperimentWorkspaceCapableLicensing} // No license: legacy count, even with the experiment on. entitlements, err := license.Entitlements(ctx, testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, authorizer, experimentOn) @@ -339,7 +339,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }).Valid(now))} entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { t.Fatal("count fn must not be called without the addon") return 0, nil @@ -352,14 +352,14 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("AddonMissingDependenciesIgnored", func(t *testing.T) { // A license carrying the addon without its required features // records a validation error and the addon is skipped, so - // permission-based counting must not activate. + // workspace-capable counting must not activate. opts := (&coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureUserLimit: 100}, }).Valid(now) opts.Addons = append(opts.Addons, codersdk.AddonAIGovernance) entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{dbLicense(*opts)}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { t.Fatal("count fn must not be called when addon dependencies are unmet") return 0, nil @@ -390,7 +390,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, ActiveAISeatCount: 5, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 3, nil }, @@ -428,7 +428,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { run := func(t *testing.T, activeUsers, capableUsers int64) codersdk.Entitlements { entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: activeUsers, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return capableUsers, nil }, @@ -483,7 +483,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { } entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 250, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 90, nil }, @@ -510,7 +510,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { } entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 80, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 30, nil }, @@ -534,7 +534,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { } entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 500, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 150, nil }, @@ -553,7 +553,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("ModeWithoutFnIsDevError", func(t *testing.T) { _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, }) require.ErrorContains(t, err, "dev error") }) @@ -564,7 +564,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { // claiming that many "active users" exist. entitlements, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 150, nil }, @@ -583,7 +583,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { }).GracePeriod(now).AIGovernanceAddon(10))} entitlements, err := license.LicensesEntitlements(ctx, now, licenses, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 3, nil }, @@ -605,7 +605,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { // different count. _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 0, xerrors.New("boom") }, @@ -617,7 +617,7 @@ func TestCountWorkspaceCapableUsers(t *testing.T) { t.Run("ContextCanceledBails", func(t *testing.T) { _, err := license.LicensesEntitlements(ctx, now, []database.License{addonLicense()}, enablements, coderdenttest.Keys, license.FeatureArguments{ ActiveUserCount: 7, - UserCountingMode: license.UserCountingModePermissionBased, + UserCountingMode: license.UserCountingModeWorkspaceCapable, WorkspaceCapableUserCountFn: func(context.Context) (int64, error) { return 0, context.Canceled }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 88c48db79d3..df238f98d8c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4841,8 +4841,8 @@ export type Experiment = | "nats_pubsub" | "notifications" | "oauth2" - | "permission-based-licensing" | "workspace-build-updates" + | "workspace-capable-licensing" | "workspace-usage"; export const Experiments: Experiment[] = [ @@ -4856,8 +4856,8 @@ export const Experiments: Experiment[] = [ "nats_pubsub", "notifications", "oauth2", - "permission-based-licensing", "workspace-build-updates", + "workspace-capable-licensing", "workspace-usage", ]; From 8bc6b9dfe132c4139004c39af58929c5586b5796 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Fri, 24 Jul 2026 21:53:26 +0000 Subject: [PATCH 34/41] refactor(enterprise/coderd/license): always provide the workspace-capable 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. --- enterprise/coderd/license/license.go | 28 ++++++++++----------- enterprise/coderd/license/usercount.go | 4 +++ enterprise/coderd/license/usercount_test.go | 6 +++++ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 43f720b3ccc..36f230b7c50 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -58,17 +58,11 @@ func Entitlements( } // Workspace-capable licensing counts only users the RBAC engine - // authorizes to create workspaces. The counting function is provided - // exactly when the mode selects it. + // authorizes to create workspaces. The mode alone decides whether the + // counting function below is invoked. countingMode := UserCountingModeActive - var workspaceCapableUserCountFn WorkspaceCapableUserCountFn if experiments.Enabled(codersdk.ExperimentWorkspaceCapableLicensing) && authorizer != nil { countingMode = UserCountingModeWorkspaceCapable - workspaceCapableUserCountFn = func(ctx context.Context) (int64, error) { - ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) - defer cancel() - return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) - } } // nolint:gocritic // Getting active AI seat count is a system function. @@ -89,13 +83,17 @@ func Entitlements( } entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ - ActiveUserCount: activeUserCount, - ActiveAISeatCount: activeAISeatCount, - ReplicaCount: replicaCount, - ExternalAuthCount: externalAuthCount, - ExternalTemplateCount: int64(len(externalTemplates)), - UserCountingMode: countingMode, - WorkspaceCapableUserCountFn: workspaceCapableUserCountFn, + ActiveUserCount: activeUserCount, + ActiveAISeatCount: activeAISeatCount, + ReplicaCount: replicaCount, + ExternalAuthCount: externalAuthCount, + ExternalTemplateCount: int64(len(externalTemplates)), + UserCountingMode: countingMode, + WorkspaceCapableUserCountFn: func(ctx context.Context) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, workspaceCapableUserCountTimeout) + defer cancel() + return CountWorkspaceCapableUsers(ctx, logger, db, authorizer) + }, ManagedAgentCountFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { // This is not super accurate, as the start and end times will be // truncated to the date in UTC timezone. This is an optimization diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 6c2a99ea075..3a46b51b671 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -22,6 +22,10 @@ import ( // role such as owner. System users and service accounts are excluded by // the underlying query, matching GetActiveUserCount. func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer) (int64, error) { + if authorizer == nil { + return 0, xerrors.New("dev error: authorizer is required") + } + start := time.Now() // All custom roles are prefetched into the context's role cache in a diff --git a/enterprise/coderd/license/usercount_test.go b/enterprise/coderd/license/usercount_test.go index 3c58c03c10a..52751096451 100644 --- a/enterprise/coderd/license/usercount_test.go +++ b/enterprise/coderd/license/usercount_test.go @@ -640,6 +640,12 @@ func TestCountWorkspaceCapableUsersErrors(t *testing.T) { prefetchParams := database.CustomRolesParams{IncludeSystemRoles: true} + t.Run("NilAuthorizer", func(t *testing.T) { + mDB := dbmock.NewMockStore(gomock.NewController(t)) + _, err := license.CountWorkspaceCapableUsers(ctx, testutil.Logger(t), mDB, nil) + require.ErrorContains(t, err, "dev error") + }) + t.Run("PrefetchError", func(t *testing.T) { mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().CustomRoles(gomock.Any(), prefetchParams).Return(nil, xerrors.New("boom")) From 5cd2ac1c1e1a890ce7e86d47192ccd8415b30c5d Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Fri, 24 Jul 2026 21:55:16 +0000 Subject: [PATCH 35/41] docs(enterprise/coderd/license): illustrate selectUserLimit's pairing with a worked example --- enterprise/coderd/license/license.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 36f230b7c50..40229e93820 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -223,6 +223,16 @@ type userLimitSelection struct { // license's counting mode. A candidate satisfied by its count wins over // any unsatisfied one. // +// For example, with 250 active users of whom 90 are workspace-capable, a +// deployment holding a 200-seat non-addon license and a 100-seat AI +// Governance license is compliant: the non-addon pair is over +// (250 > 200), but the addon pair is satisfied (90 <= 100) and is +// selected. With 180 active users of whom 150 are workspace-capable, the +// same licenses select the non-addon pair instead (180 <= 200 while +// 150 > 100). Neither license's limit is ever paired with the other's +// count: 90 capable users against the 200-seat limit, or 180 active +// users against the 100-seat limit, are not considered. +// // When an addon candidate is selected, the capable count overwrites // featureArguments.ActiveUserCount, which the FeatureUserLimit feature's // Actual pointer aliases: Feature values copy the pointer, not the From d6dd818c29e87e7f433d2e3413111588f241c344 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Sun, 26 Jul 2026 18:39:39 +0000 Subject: [PATCH 36/41] refactor(enterprise/coderd/license): key seat-count dedupe on hashed 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. --- coderd/database/querier.go | 8 +- coderd/database/queries.sql.go | 28 ++-- coderd/database/queries/users.sql | 21 ++- enterprise/coderd/license/usercount.go | 128 +++++++++++------- .../coderd/license/usercount_internal_test.go | 93 ------------- 5 files changed, 113 insertions(+), 165 deletions(-) delete mode 100644 enterprise/coderd/license/usercount_internal_test.go diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f8af4aaa668..1eba5a4ce48 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -342,11 +342,9 @@ type sqlcQuerier interface { GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error) GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error) // Returns the authorization roles (site and org-scoped, including implied - // member roles and organization default roles) for every active, non-deleted - // user who is neither a system user nor a service account, matching the - // GetActiveUserCount population. Group memberships are not returned, so the - // results only support authorization decisions on objects without ACLs: - // groups influence authorization solely through object ACL matching. + // member roles and organization default roles) and the group memberships + // for every active, non-deleted user who is neither a system user nor a + // service account, matching the GetActiveUserCount population. GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) // For PG Coordinator HTMLDebug diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 89a6c14f535..ac316f0f50e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30201,6 +30201,15 @@ WITH org_roles AS ( ) AS org_role GROUP BY organization_members.user_id +), +user_groups AS ( + SELECT + group_members.user_id, + array_agg(group_members.group_id :: text) AS groups + FROM + group_members + GROUP BY + group_members.user_id ) SELECT users.id, @@ -30209,10 +30218,12 @@ SELECT array_append(users.rbac_roles, 'member'), -- Users with no org memberships have no org_roles row. coalesce(org_roles.roles, ARRAY[]::text[]) - ) :: text[] AS roles + ) :: text[] AS roles, + coalesce(user_groups.groups, ARRAY[]::text[]) :: text[] AS groups FROM users LEFT JOIN org_roles ON org_roles.user_id = users.id + LEFT JOIN user_groups ON user_groups.user_id = users.id WHERE users.status = 'active'::user_status AND users.deleted = false @@ -30221,16 +30232,15 @@ WHERE ` type GetActiveUsersAuthorizationRolesRow struct { - ID uuid.UUID `db:"id" json:"id"` - Roles []string `db:"roles" json:"roles"` + ID uuid.UUID `db:"id" json:"id"` + Roles []string `db:"roles" json:"roles"` + Groups []string `db:"groups" json:"groups"` } // Returns the authorization roles (site and org-scoped, including implied -// member roles and organization default roles) for every active, non-deleted -// user who is neither a system user nor a service account, matching the -// GetActiveUserCount population. Group memberships are not returned, so the -// results only support authorization decisions on objects without ACLs: -// groups influence authorization solely through object ACL matching. +// member roles and organization default roles) and the group memberships +// for every active, non-deleted user who is neither a system user nor a +// service account, matching the GetActiveUserCount population. func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) { rows, err := q.db.QueryContext(ctx, getActiveUsersAuthorizationRoles) if err != nil { @@ -30240,7 +30250,7 @@ func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]Ge var items []GetActiveUsersAuthorizationRolesRow for rows.Next() { var i GetActiveUsersAuthorizationRolesRow - if err := rows.Scan(&i.ID, pq.Array(&i.Roles)); err != nil { + if err := rows.Scan(&i.ID, pq.Array(&i.Roles), pq.Array(&i.Groups)); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 86fc30a56df..62bd9947924 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -655,11 +655,9 @@ WHERE -- name: GetActiveUsersAuthorizationRoles :many -- Returns the authorization roles (site and org-scoped, including implied --- member roles and organization default roles) for every active, non-deleted --- user who is neither a system user nor a service account, matching the --- GetActiveUserCount population. Group memberships are not returned, so the --- results only support authorization decisions on objects without ACLs: --- groups influence authorization solely through object ACL matching. +-- member roles and organization default roles) and the group memberships +-- for every active, non-deleted user who is neither a system user nor a +-- service account, matching the GetActiveUserCount population. WITH org_roles AS ( SELECT organization_members.user_id, @@ -685,6 +683,15 @@ WITH org_roles AS ( ) AS org_role GROUP BY organization_members.user_id +), +user_groups AS ( + SELECT + group_members.user_id, + array_agg(group_members.group_id :: text) AS groups + FROM + group_members + GROUP BY + group_members.user_id ) SELECT users.id, @@ -693,10 +700,12 @@ SELECT array_append(users.rbac_roles, 'member'), -- Users with no org memberships have no org_roles row. coalesce(org_roles.roles, ARRAY[]::text[]) - ) :: text[] AS roles + ) :: text[] AS roles, + coalesce(user_groups.groups, ARRAY[]::text[]) :: text[] AS groups FROM users LEFT JOIN org_roles ON org_roles.user_id = users.id + LEFT JOIN user_groups ON user_groups.user_id = users.id WHERE users.status = 'active'::user_status AND users.deleted = false diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 3a46b51b671..0aa01a9791b 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -2,10 +2,13 @@ package license import ( "context" + "crypto/sha256" + "encoding/json" "slices" "strings" "time" + "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -16,6 +19,12 @@ import ( "github.com/coder/coder/v2/coderd/rbac/rolestore" ) +// countingSubjectID replaces the real user ID in every evaluated subject +// and on the object owner. The policy only ever compares the subject ID +// to the object owner, so setting both to the same value preserves the +// outcome while making subjects with equal roles and groups byte-identical. +var countingSubjectID = uuid.MustParse("ad966897-b805-4a2c-8dab-3cfcbba0a683").String() + // CountWorkspaceCapableUsers returns the number of active users the RBAC // engine authorizes to create a workspace, either in one of the // organizations they belong to or in any organization via a site-wide @@ -43,21 +52,34 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data return 0, xerrors.Errorf("get active users authorization roles: %w", err) } - // Users with identical role sets always produce the same authorization - // outcome: the subject ID and the object owner are the same user in - // every check, and the objects carry no ACLs, so group membership - // (which only influences authorization through object ACL matching) - // cannot change the result. Deduplicate on the role signature so - // evaluation cost scales with the number of unique role sets, not the - // number of users. TestWorkspaceCreateIgnoresGroups enforces the - // group-independence assumption. - capableBySignature := make(map[string]bool) + // Subjects are deduplicated on a hash of their canonical JSON form: + // the user ID is normalized to countingSubjectID and the roles and + // groups are sorted, so users whose subjects are equivalent share one + // authorization verdict. Evaluation cost scales with the number of + // unique subjects, not the number of users. + capableBySignature := make(map[[sha256.Size]byte]bool) var count int64 for _, row := range rows { - sig := authorizationSignature(row) + roleNames, err := row.RoleNames() + if err != nil { + // A stored role string that fails to parse grants nothing: + // authorization fails closed on it, so this user cannot + // create a workspace. Treat the user as not capable instead + // of failing the entire count. + logger.Warn(ctx, "user has an unparsable role, counting them as not workspace-capable for license seats", + slog.F("user_id", row.ID), + slog.Error(err), + ) + continue + } + subject := countingSubject(roleNames, row.Groups) + sig, err := authorizationSignature(subject) + if err != nil { + return 0, xerrors.Errorf("compute authorization signature for user %s: %w", row.ID, err) + } capable, ok := capableBySignature[sig] if !ok { - capable, err = canCreateWorkspace(ctx, logger, db, authorizer, row) + capable, err = canCreateWorkspace(ctx, db, authorizer, subject) if err != nil { return 0, xerrors.Errorf("evaluate workspace-create for user %s: %w", row.ID, err) } @@ -74,46 +96,60 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data logger.Info(ctx, "counted workspace-capable users for license seats", slog.F("workspace_capable_users", count), slog.F("active_users", len(rows)), - slog.F("unique_role_sets", len(capableBySignature)), + slog.F("unique_subjects", len(capableBySignature)), slog.F("elapsed", time.Since(start)), ) return count, nil } -// canCreateWorkspace reports whether the RBAC engine authorizes the user -// to create a workspace they own in any organization: via membership -// grants or via a site-wide role that applies regardless of membership. -func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Store, authorizer rbac.Authorizer, row database.GetActiveUsersAuthorizationRolesRow) (bool, error) { - roleNames, err := row.RoleNames() - if err != nil { - // A stored role string that fails to parse grants nothing: - // authorization fails closed on it, so this user cannot create a - // workspace. Treat the user as not capable instead of failing the - // entire count. Logged once per unique role set due to the - // signature dedupe. - logger.Warn(ctx, "user has an unparsable role, counting them as not workspace-capable for license seats", - slog.F("user_id", row.ID), - slog.Error(err), - ) - return false, nil +// countingSubject builds the canonical evaluation subject for a user: +// the normalized subject ID, sorted deduplicated roles and groups, and +// the unrestricted scope. +func countingSubject(roleNames []rbac.RoleIdentifier, groups []string) rbac.Subject { + slices.SortFunc(roleNames, func(a, b rbac.RoleIdentifier) int { + return strings.Compare(a.String(), b.String()) + }) + roleNames = slices.CompactFunc(roleNames, func(a, b rbac.RoleIdentifier) bool { + return a == b + }) + groups = slices.Clone(groups) + slices.Sort(groups) + groups = slices.Compact(groups) + return rbac.Subject{ + Type: rbac.SubjectTypeUser, + ID: countingSubjectID, + Roles: rbac.RoleIdentifiers(roleNames), + Groups: groups, + Scope: rbac.ScopeAll, } +} +// authorizationSignature returns a hash of the subject's JSON form. Two +// users with equal signatures are interchangeable for workspace-create +// evaluation: every subject field, including any added later, is part of +// the key. +func authorizationSignature(subject rbac.Subject) ([sha256.Size]byte, error) { + var sig [sha256.Size]byte + hash := sha256.New() + if err := json.NewEncoder(hash).Encode(subject); err != nil { + return sig, xerrors.Errorf("encode subject: %w", err) + } + copy(sig[:], hash.Sum(nil)) + return sig, nil +} + +// canCreateWorkspace reports whether the RBAC engine authorizes the +// subject to create a workspace they own in any organization: via +// membership grants or via a site-wide role that applies regardless of +// membership. +func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac.Authorizer, subject rbac.Subject) (bool, error) { //nolint:gocritic // Expanding custom roles requires system access. - roles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, roleNames) + roles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, subject.SafeRoleNames()) if err != nil { return false, xerrors.Errorf("expand roles: %w", err) } - - subject := rbac.Subject{ - Type: rbac.SubjectTypeUser, - ID: row.ID.String(), - Roles: roles, - // Groups are deliberately omitted: they only influence - // authorization through object ACL matching, and the objects - // below carry no ACLs. Keeping them off the subject keeps the - // role-only dedupe signature honest. - Scope: rbac.ScopeAll, - }.WithCachedASTValue() + subject.Roles = roles + subject = subject.WithCachedASTValue() // A site-wide grant (e.g. the owner role) authorizes creation in any // organization, and the any-organization policy form resolves to the @@ -124,15 +160,3 @@ func canCreateWorkspace(ctx context.Context, logger slog.Logger, db database.Sto return authorizer.Authorize(ctx, subject, policy.ActionCreate, rbac.ResourceWorkspace.AnyOrganization().WithOwner(subject.ID)) == nil, nil } - -// authorizationSignature returns a canonical key for the user's role set. -// Roles are sorted and deduplicated, so equivalent sets share a key. Two -// users with equal signatures are interchangeable for workspace-create -// evaluation. -func authorizationSignature(row database.GetActiveUsersAuthorizationRolesRow) string { - roles := make([]string, len(row.Roles)) - copy(roles, row.Roles) - slices.Sort(roles) - roles = slices.Compact(roles) - return strings.Join(roles, "\x00") -} diff --git a/enterprise/coderd/license/usercount_internal_test.go b/enterprise/coderd/license/usercount_internal_test.go deleted file mode 100644 index 25a5257cd59..00000000000 --- a/enterprise/coderd/license/usercount_internal_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package license - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" -) - -// TestWorkspaceCreateIgnoresGroups reinforces the assumption that lets -// CountWorkspaceCapableUsers deduplicate on roles alone and omit groups -// from the evaluation subject: group membership must not change the -// workspace-create outcome for the objects canCreateWorkspace uses. -// Those objects carry no ACLs, and groups only influence authorization -// through object ACL matching, so a subject with groups and one without -// must authorize identically. -// -// If this test fails, the policy has become group-sensitive for -// ACL-less workspace objects. Groups must then be added back to the -// evaluation subject, the dedupe signature, and the -// GetActiveUsersAuthorizationRoles query, or the cached verdicts will -// be shared across users with different authorization outcomes. -func TestWorkspaceCreateIgnoresGroups(t *testing.T) { - t.Parallel() - - auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) - orgID := uuid.New() - userID := uuid.New() - - roleSets := map[string][]rbac.RoleIdentifier{ - "member only": {rbac.RoleMember()}, - "org member floor": { - rbac.RoleMember(), - rbac.ScopedRoleOrgMember(orgID), - }, - "workspace access": { - rbac.RoleMember(), - rbac.ScopedRoleOrgMember(orgID), - rbac.ScopedRoleOrgWorkspaceAccess(orgID), - }, - "creation ban": { - rbac.RoleMember(), - rbac.ScopedRoleOrgMember(orgID), - rbac.ScopedRoleOrgWorkspaceAccess(orgID), - rbac.ScopedRoleOrgWorkspaceCreationBan(orgID), - }, - "org admin": { - rbac.RoleMember(), - rbac.ScopedRoleOrgAdmin(orgID), - }, - "owner": {rbac.RoleMember(), rbac.RoleOwner()}, - } - - // The org ID doubles as the Everyone group ID, making it the - // adversarial group membership for ACL-related rules. - groups := []string{orgID.String(), uuid.NewString(), uuid.NewString()} - - // The same object shapes canCreateWorkspace evaluates: no ACLs. - objects := map[string]rbac.Object{ - "any org": rbac.ResourceWorkspace.AnyOrganization().WithOwner(userID.String()), - "in org": rbac.ResourceWorkspace.InOrg(orgID).WithOwner(userID.String()), - } - - for name, roleNames := range roleSets { - t.Run(name, func(t *testing.T) { - t.Parallel() - - base := rbac.Subject{ - Type: rbac.SubjectTypeUser, - ID: userID.String(), - Roles: rbac.RoleIdentifiers(roleNames), - Scope: rbac.ScopeAll, - } - withGroups := base - withGroups.Groups = groups - - for objName, obj := range objects { - errWithout := auth.Authorize(context.Background(), base, policy.ActionCreate, obj) - errWith := auth.Authorize(context.Background(), withGroups, policy.ActionCreate, obj) - require.Equal(t, - errWithout == nil, errWith == nil, - "object %q: outcome must not depend on groups (without: %v, with: %v)", - objName, errWithout, errWith, - ) - } - }) - } -} From 2fd635c1b157d3c3580dc7c832bab17bbccd2161 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Sun, 26 Jul 2026 18:48:46 +0000 Subject: [PATCH 37/41] chore(enterprise/coderd/license): deduplicate canonicalization commentary --- enterprise/coderd/license/usercount.go | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index 0aa01a9791b..fdca912b574 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -52,11 +52,8 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data return 0, xerrors.Errorf("get active users authorization roles: %w", err) } - // Subjects are deduplicated on a hash of their canonical JSON form: - // the user ID is normalized to countingSubjectID and the roles and - // groups are sorted, so users whose subjects are equivalent share one - // authorization verdict. Evaluation cost scales with the number of - // unique subjects, not the number of users. + // Users with equivalent canonical subjects share one authorization + // verdict, so evaluation cost scales with unique subjects, not users. capableBySignature := make(map[[sha256.Size]byte]bool) var count int64 for _, row := range rows { @@ -102,9 +99,7 @@ func CountWorkspaceCapableUsers(ctx context.Context, logger slog.Logger, db data return count, nil } -// countingSubject builds the canonical evaluation subject for a user: -// the normalized subject ID, sorted deduplicated roles and groups, and -// the unrestricted scope. +// countingSubject builds the canonical evaluation subject for a user. func countingSubject(roleNames []rbac.RoleIdentifier, groups []string) rbac.Subject { slices.SortFunc(roleNames, func(a, b rbac.RoleIdentifier) int { return strings.Compare(a.String(), b.String()) @@ -124,10 +119,8 @@ func countingSubject(roleNames []rbac.RoleIdentifier, groups []string) rbac.Subj } } -// authorizationSignature returns a hash of the subject's JSON form. Two -// users with equal signatures are interchangeable for workspace-create -// evaluation: every subject field, including any added later, is part of -// the key. +// authorizationSignature returns a hash of the subject's JSON form; +// every subject field, including any added later, is part of the key. func authorizationSignature(subject rbac.Subject) ([sha256.Size]byte, error) { var sig [sha256.Size]byte hash := sha256.New() @@ -151,12 +144,9 @@ func canCreateWorkspace(ctx context.Context, db database.Store, authorizer rbac. subject.Roles = roles subject = subject.WithCachedASTValue() - // A site-wide grant (e.g. the owner role) authorizes creation in any - // organization, and the any-organization policy form resolves to the - // maximum per-org vote across the subject's memberships, so it also - // subsumes per-organization checks: it allows exactly when some - // InOrg(id) check would. This also covers users who belong to zero - // organizations. + // The any-organization form allows exactly when some per-organization + // check would (the policy takes the maximum vote across the subject's + // memberships), and also covers users who belong to zero organizations. return authorizer.Authorize(ctx, subject, policy.ActionCreate, rbac.ResourceWorkspace.AnyOrganization().WithOwner(subject.ID)) == nil, nil } From a93de24043b8a7d641049940185b68ee3ae71810 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 27 Jul 2026 18:29:04 +0000 Subject: [PATCH 38/41] chore: address review feedback on seat counting - Add a parity test asserting GetActiveUsersAuthorizationRoles matches GetAuthorizationUserRoles per user (roles and groups), with cross-references on both queries. - Warn when the workspace-capable-licensing experiment is enabled without an authorizer, and leave a TODO to make that a hard dev error once the experiment term is removed. - Extend the countingSubjectID doc with the ACL half of the safety argument. - Present the selectUserLimit example as a table. --- coderd/database/querier.go | 5 ++ coderd/database/querier_test.go | 89 ++++++++++++++++++++++++++ coderd/database/queries.sql.go | 5 ++ coderd/database/queries/users.sql | 5 ++ enterprise/coderd/license/license.go | 32 +++++---- enterprise/coderd/license/usercount.go | 8 ++- 6 files changed, 130 insertions(+), 14 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1eba5a4ce48..92e1438d37c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -345,6 +345,8 @@ type sqlcQuerier interface { // member roles and organization default roles) and the group memberships // for every active, non-deleted user who is neither a system user nor a // service account, matching the GetActiveUserCount population. + // Must stay semantically in sync with GetAuthorizationUserRoles; + // TestGetActiveUsersAuthorizationRolesParity enforces this. GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) // For PG Coordinator HTMLDebug @@ -369,6 +371,9 @@ type sqlcQuerier interface { GetAuthenticatedWorkspaceAgentAndBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetAuthenticatedWorkspaceAgentAndBuildByAuthTokenRow, error) // This function returns roles for authorization purposes. Implied member roles // are included. + // Must stay semantically in sync with GetActiveUsersAuthorizationRoles + // (implied member roles, org default roles, groups); + // TestGetActiveUsersAuthorizationRolesParity enforces this. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) // Returns read-only root chat candidates for state-machine-backed // auto-archive. Activity is computed across the root family. The query diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 305cf43a1cb..dc5e7afecde 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -17723,3 +17723,92 @@ func requireAIGatewayKeysViolation( require.FailNow(t, "test case must expect a constraint error") } } + +// TestGetActiveUsersAuthorizationRolesParity verifies that the bulk +// GetActiveUsersAuthorizationRoles query returns, for every eligible +// user, the same roles and groups as the per-user +// GetAuthorizationUserRoles query. The two queries encode the implied +// member roles, organization default roles, and group memberships +// independently and must not drift. +func TestGetActiveUsersAuthorizationRolesParity(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + orgA := dbgen.Organization(t, db, database.Organization{}) + orgB := dbgen.Organization(t, db, database.Organization{}) + + activeUser := func(seed database.User) database.User { + seed.Status = database.UserStatusActive + return dbgen.User(t, db, seed) + } + member := func(orgID uuid.UUID, user database.User, roles ...string) { + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgID, + UserID: user.ID, + Roles: roles, + }) + } + + // Site-wide role, zero org memberships. + owner := activeUser(database.User{RBACRoles: []string{rbac.RoleOwner().Name}}) + + // Plain single-org member; effective roles come from the implied + // member role plus the org's default member roles. + plain := activeUser(database.User{}) + member(orgA.ID, plain) + + // Explicit org roles across two organizations. + multiOrg := activeUser(database.User{}) + member(orgA.ID, multiOrg, rbac.RoleOrgAdmin()) + member(orgB.ID, multiOrg) + + // Custom org role. + customRole, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: "parity-role", + DisplayName: "Parity Role", + OrganizationID: uuid.NullUUID{UUID: orgA.ID, Valid: true}, + OrgPermissions: []database.CustomRolePermission{{ + ResourceType: rbac.ResourceWorkspace.Type, + Action: policy.ActionCreate, + }}, + }) + require.NoError(t, err) + custom := activeUser(database.User{}) + member(orgA.ID, custom, customRole.Name) + + // Group memberships. + grouped := activeUser(database.User{}) + member(orgA.ID, grouped) + for range 2 { + group := dbgen.Group(t, db, database.Group{OrganizationID: orgA.ID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{ + UserID: grouped.ID, + GroupID: group.ID, + }) + } + + // Excluded from the bulk query: service accounts and non-active + // users. + sa := activeUser(database.User{IsServiceAccount: true}) + member(orgA.ID, sa) + suspended := dbgen.User(t, db, database.User{Status: database.UserStatusSuspended}) + member(orgA.ID, suspended) + + rows, err := db.GetActiveUsersAuthorizationRoles(ctx) + require.NoError(t, err) + + gotIDs := make([]uuid.UUID, 0, len(rows)) + for _, row := range rows { + gotIDs = append(gotIDs, row.ID) + } + require.ElementsMatch(t, []uuid.UUID{owner.ID, plain.ID, multiOrg.ID, custom.ID, grouped.ID}, gotIDs) + + for _, row := range rows { + single, err := db.GetAuthorizationUserRoles(ctx, row.ID) + require.NoError(t, err) + require.ElementsMatch(t, single.Roles, row.Roles, "roles diverged for user %s", row.ID) + require.ElementsMatch(t, single.Groups, row.Groups, "groups diverged for user %s", row.ID) + } +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ac316f0f50e..70ffa52d094 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30241,6 +30241,8 @@ type GetActiveUsersAuthorizationRolesRow struct { // member roles and organization default roles) and the group memberships // for every active, non-deleted user who is neither a system user nor a // service account, matching the GetActiveUserCount population. +// Must stay semantically in sync with GetAuthorizationUserRoles; +// TestGetActiveUsersAuthorizationRolesParity enforces this. func (q *sqlQuerier) GetActiveUsersAuthorizationRoles(ctx context.Context) ([]GetActiveUsersAuthorizationRolesRow, error) { rows, err := q.db.QueryContext(ctx, getActiveUsersAuthorizationRoles) if err != nil { @@ -30336,6 +30338,9 @@ type GetAuthorizationUserRolesRow struct { // This function returns roles for authorization purposes. Implied member roles // are included. +// Must stay semantically in sync with GetActiveUsersAuthorizationRoles +// (implied member roles, org default roles, groups); +// TestGetActiveUsersAuthorizationRolesParity enforces this. func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) { row := q.db.QueryRowContext(ctx, getAuthorizationUserRoles, userID) var i GetAuthorizationUserRolesRow diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 62bd9947924..e283b43c6be 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -594,6 +594,9 @@ WHERE -- name: GetAuthorizationUserRoles :one -- This function returns roles for authorization purposes. Implied member roles -- are included. +-- Must stay semantically in sync with GetActiveUsersAuthorizationRoles +-- (implied member roles, org default roles, groups); +-- TestGetActiveUsersAuthorizationRolesParity enforces this. SELECT -- username and email are returned just to help for logging purposes -- status is used to enforce 'suspended' users, as all roles are ignored @@ -658,6 +661,8 @@ WHERE -- member roles and organization default roles) and the group memberships -- for every active, non-deleted user who is neither a system user nor a -- service account, matching the GetActiveUserCount population. +-- Must stay semantically in sync with GetAuthorizationUserRoles; +-- TestGetActiveUsersAuthorizationRolesParity enforces this. WITH org_roles AS ( SELECT organization_members.user_id, diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 40229e93820..693a382ac26 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -60,9 +60,18 @@ func Entitlements( // Workspace-capable licensing counts only users the RBAC engine // authorizes to create workspaces. The mode alone decides whether the // counting function below is invoked. + // + // TODO: when the workspace-capable-licensing experiment is removed, a + // nil authorizer must become a hard dev error rather than a silent + // fallback to active-user counting, with tests selecting the legacy + // mode explicitly instead of passing nil. countingMode := UserCountingModeActive - if experiments.Enabled(codersdk.ExperimentWorkspaceCapableLicensing) && authorizer != nil { - countingMode = UserCountingModeWorkspaceCapable + if experiments.Enabled(codersdk.ExperimentWorkspaceCapableLicensing) { + if authorizer == nil { + logger.Warn(ctx, "workspace-capable licensing experiment is enabled but no authorizer is configured, counting all active users") + } else { + countingMode = UserCountingModeWorkspaceCapable + } } // nolint:gocritic // Getting active AI seat count is a system function. @@ -223,15 +232,16 @@ type userLimitSelection struct { // license's counting mode. A candidate satisfied by its count wins over // any unsatisfied one. // -// For example, with 250 active users of whom 90 are workspace-capable, a -// deployment holding a 200-seat non-addon license and a 100-seat AI -// Governance license is compliant: the non-addon pair is over -// (250 > 200), but the addon pair is satisfied (90 <= 100) and is -// selected. With 180 active users of whom 150 are workspace-capable, the -// same licenses select the non-addon pair instead (180 <= 200 while -// 150 > 100). Neither license's limit is ever paired with the other's -// count: 90 capable users against the 200-seat limit, or 180 active -// users against the 100-seat limit, are not considered. +// For example, a deployment holding a 200-seat non-addon license and a +// 100-seat AI Governance license: +// +// active | capable | 200-seat pair | 100-seat pair | selected +// 250 | 90 | over | satisfied | addon: 90/100 +// 180 | 150 | satisfied | over | non-addon: 180/200 +// +// Neither license's limit is ever paired with the other's count: 90 +// capable users against the 200-seat limit, or 180 active users against +// the 100-seat limit, are not considered. // // When an addon candidate is selected, the capable count overwrites // featureArguments.ActiveUserCount, which the FeatureUserLimit feature's diff --git a/enterprise/coderd/license/usercount.go b/enterprise/coderd/license/usercount.go index fdca912b574..3699456d6d8 100644 --- a/enterprise/coderd/license/usercount.go +++ b/enterprise/coderd/license/usercount.go @@ -20,9 +20,11 @@ import ( ) // countingSubjectID replaces the real user ID in every evaluated subject -// and on the object owner. The policy only ever compares the subject ID -// to the object owner, so setting both to the same value preserves the -// outcome while making subjects with equal roles and groups byte-identical. +// and on the object owner. The substitution is safe because the policy +// only ever compares the subject ID to the object owner, and the +// evaluated object is synthetic with no user or group ACL lists, so no +// other rule can reference a real ID. Subjects with equal roles and +// groups are therefore byte-identical. var countingSubjectID = uuid.MustParse("ad966897-b805-4a2c-8dab-3cfcbba0a683").String() // CountWorkspaceCapableUsers returns the number of active users the RBAC From 0899b8d035416f3ccda4e377372cd93ccfb23bfe Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 27 Jul 2026 18:39:30 +0000 Subject: [PATCH 39/41] refactor(enterprise/coderd/license): let selectUserLimit own the enforced count Instead of writing the selected count through the pointer alias between the FeatureUserLimit feature's Actual and featureArguments.ActiveUserCount,\nselectUserLimit sets Actual to the selected candidate's own count, and the warnings read the count from the feature they warn about. The displayed value and the warned value are the same field, so they cannot diverge; featureArguments.ActiveUserCount is never mutated and remains the legacy count for the revert warning. Removes the pointer-identity and ordering invariants and their commentary. --- enterprise/coderd/license/license.go | 41 +++++++++++----------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 693a382ac26..cfca4e0dc96 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -215,10 +215,6 @@ type userLimitSelection struct { // workspaceCapable is true when the selected candidate counts // workspace-capable users rather than all active users. workspaceCapable bool - // legacyActiveUserCount is the all-active-users count that applied - // before the capable count overwrote it. Only set when - // workspaceCapable is true. - legacyActiveUserCount int64 // addonEntitled is true when at least one addon-carrying candidate is // fully valid rather than in its grace period. addonEntitled bool @@ -243,20 +239,12 @@ type userLimitSelection struct { // capable users against the 200-seat limit, or 180 active users against // the 100-seat limit, are not considered. // -// When an addon candidate is selected, the capable count overwrites -// featureArguments.ActiveUserCount, which the FeatureUserLimit feature's -// Actual pointer aliases: Feature values copy the pointer, not the -// int64, so every copy of the feature observes the write, as do the -// caller's over-limit warnings. featureArguments must therefore point -// at the caller's copy, and replacing Actual with a fresh allocation -// would break this. -// // With no candidates the entitlements are left untouched. On a count // failure the entitlements computation must be aborted. func selectUserLimit( ctx context.Context, entitlements *codersdk.Entitlements, - featureArguments *FeatureArguments, + featureArguments FeatureArguments, candidates []userLimitCandidate, ) (userLimitSelection, error) { var sel userLimitSelection @@ -307,18 +295,17 @@ func selectUserLimit( } if best.aiGovernanceAddon && capableCount != nil { - sel.legacyActiveUserCount = featureArguments.ActiveUserCount - featureArguments.ActiveUserCount = best.count sel.workspaceCapable = true } // AddFeature merged limits and entitlements across licenses without // pairing them to counting modes; overwrite the merged terms with the - // selected candidate's. Actual keeps aliasing - // featureArguments.ActiveUserCount. + // selected candidate's. Actual is replaced wholesale, so the merged + // feature's alias of the caller's ActiveUserCount no longer matters. userLimit := entitlements.Features[codersdk.FeatureUserLimit] userLimit.Limit = &best.limit userLimit.Entitlement = best.entitlement + userLimit.Actual = &best.count entitlements.Features[codersdk.FeatureUserLimit] = userLimit return sel, nil } @@ -623,7 +610,7 @@ func LicensesEntitlements( // The FeatureUserLimit feature's final terms come from best-pair selection // across the candidates rather than the AddFeature merge. - userLimitSel, err := selectUserLimit(ctx, &entitlements, &featureArguments, userLimitCandidates) + userLimitSel, err := selectUserLimit(ctx, &entitlements, featureArguments, userLimitCandidates) if err != nil { return entitlements, err } @@ -722,21 +709,25 @@ func LicensesEntitlements( if entitlements.HasLicense { userLimit := entitlements.Features[codersdk.FeatureUserLimit] - // With workspace-capable counting, ActiveUserCount holds only - // workspace-capable users, not all active users; the warning must - // name what was counted. + // The enforced count and its meaning come from the selected + // candidate: userLimit.Actual is the count the limit was evaluated + // against, and the noun names what it counted. + userLimitActual := featureArguments.ActiveUserCount + if userLimit.Actual != nil { + userLimitActual = *userLimit.Actual + } userNoun := "active users" if userLimitSel.workspaceCapable { userNoun = "workspace-capable users" } - if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit { + if userLimit.Limit != nil && userLimitActual > *userLimit.Limit { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your deployment has %d %s but is only licensed for %d.", - featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) + userLimitActual, userNoun, *userLimit.Limit)) } else if userLimit.Limit != nil && userLimit.Entitlement == codersdk.EntitlementGracePeriod { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your deployment has %d %s but the license with the limit %d is expired.", - featureArguments.ActiveUserCount, userNoun, *userLimit.Limit)) + userLimitActual, userNoun, *userLimit.Limit)) } // The addon exists only on grace-period licenses: warn that // workspace-capable counting stops at the end of the grace period, @@ -744,7 +735,7 @@ func LicensesEntitlements( if userLimitSel.workspaceCapable && !userLimitSel.addonEntitled { entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( "Your license with the AI Governance addon is expired. When it fully expires, all %d active users will count toward the user limit instead of the %d workspace-capable users.", - userLimitSel.legacyActiveUserCount, featureArguments.ActiveUserCount)) + featureArguments.ActiveUserCount, userLimitActual)) } if featureArguments.ActiveAISeatCount > 0 { actual := featureArguments.ActiveAISeatCount From b02ca8a0ae7ccd4eff4472b9c008fdbe839fab33 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 27 Jul 2026 19:01:20 +0000 Subject: [PATCH 40/41] chore(enterprise/coderd/license): name licenses instead of pairs in the selection example --- enterprise/coderd/license/license.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index cfca4e0dc96..ecef60f5ca4 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -231,9 +231,9 @@ type userLimitSelection struct { // For example, a deployment holding a 200-seat non-addon license and a // 100-seat AI Governance license: // -// active | capable | 200-seat pair | 100-seat pair | selected -// 250 | 90 | over | satisfied | addon: 90/100 -// 180 | 150 | satisfied | over | non-addon: 180/200 +// active | capable | 200-seat license | 100-seat addon license | selected +// 250 | 90 | over | satisfied | addon: 90/100 +// 180 | 150 | satisfied | over | non-addon: 180/200 // // Neither license's limit is ever paired with the other's count: 90 // capable users against the 200-seat limit, or 180 active users against From 18e35992db44a3369f9a373480e8b6c4aa2637ce Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Mon, 27 Jul 2026 19:06:35 +0000 Subject: [PATCH 41/41] test(enterprise/coderd/license): pass a real authorizer instead of nil All Entitlements test callsites now provide an authorizer; only the dedicated nil-fallback tests exercise the nil branch, which the GA TODO will turn into a hard dev error. --- enterprise/coderd/license/license.go | 5 +- enterprise/coderd/license/license_test.go | 85 ++++++++++++----------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ecef60f5ca4..8ee9ecd6a41 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -63,8 +63,9 @@ func Entitlements( // // TODO: when the workspace-capable-licensing experiment is removed, a // nil authorizer must become a hard dev error rather than a silent - // fallback to active-user counting, with tests selecting the legacy - // mode explicitly instead of passing nil. + // fallback to active-user counting. Tests already pass a real + // authorizer; only the dedicated nil-fallback tests rely on this + // branch. countingMode := UserCountingModeActive if experiments.Enabled(codersdk.ExperimentWorkspaceCapableLicensing) { if authorizer == nil { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 78e755f3d27..f41a0ee3c9b 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -16,12 +17,18 @@ import ( "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/testutil" ) +// testAuthorizer satisfies Entitlements' expectation of a non-nil +// authorizer. The callers below never enable the workspace-capable +// licensing experiment, so it is never asked to authorize anything. +var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry()) + func TestEntitlements(t *testing.T) { t.Parallel() all := make(map[codersdk.FeatureName]bool) @@ -34,7 +41,7 @@ func TestEntitlements(t *testing.T) { t.Run("Defaults", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -46,7 +53,7 @@ func TestEntitlements(t *testing.T) { t.Run("Always return the current user count", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -59,7 +66,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -87,7 +94,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -111,7 +118,7 @@ func TestEntitlements(t *testing.T) { }), Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -138,7 +145,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -167,7 +174,7 @@ func TestEntitlements(t *testing.T) { Exp: time.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -203,7 +210,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be generated. - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -231,7 +238,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should be suppressed. - entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -262,7 +269,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Should generate a warning. - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -290,7 +297,7 @@ func TestEntitlements(t *testing.T) { require.NoError(t, err) // Warning should still be generated. - entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err = license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -316,7 +323,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -345,7 +352,7 @@ func TestEntitlements(t *testing.T) { Exp: dbtime.Now().AddDate(0, 0, 5), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -365,7 +372,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -435,7 +442,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -463,7 +470,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(60 * 24 * time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -486,7 +493,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -502,7 +509,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -550,7 +557,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, licenseOptions), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -601,7 +608,7 @@ func TestEntitlements(t *testing.T) { }), }) require.NoError(t, err) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -622,7 +629,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -655,7 +662,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -689,7 +696,7 @@ func TestEntitlements(t *testing.T) { ExpiresAt: dbtime.Now().Add(time.Hour), }), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -715,7 +722,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleReplicasNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -735,7 +742,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, - }, nil, nil) + }, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -758,7 +765,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureHighAvailability: true, - }, nil, nil) + }, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Warnings, 1) @@ -768,7 +775,7 @@ func TestEntitlements(t *testing.T) { t.Run("MultipleGitAuthNoLicense", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -788,7 +795,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, - }, nil, nil) + }, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Errors, 1) @@ -811,7 +818,7 @@ func TestEntitlements(t *testing.T) { }) entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ codersdk.FeatureMultipleExternalAuth: true, - }, nil, nil) + }, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Len(t, entitlements.Warnings, 1) @@ -876,7 +883,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -994,7 +1001,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1064,7 +1071,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1127,7 +1134,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAIGovernanceUserLimit: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -1187,7 +1194,7 @@ func TestEntitlements(t *testing.T) { GetTemplatesWithFilter(gomock.Any(), gomock.Any()). Return([]database.Template{}, nil) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2164,7 +2171,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2196,7 +2203,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2234,7 +2241,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2265,7 +2272,7 @@ func TestAIGovernanceAddon(t *testing.T) { Exp: dbtime.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, empty, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense) @@ -2298,7 +2305,7 @@ func TestAIGovernanceAddon(t *testing.T) { codersdk.FeatureAIBridge: true, codersdk.FeatureBoundary: true, } - entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, nil, nil) + entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), db, 1, 1, coderdenttest.Keys, enablements, testAuthorizer, nil) require.NoError(t, err) require.True(t, entitlements.HasLicense)