feat: display the AI add-on column in the UI on the Users and Organization Members tables (#23291)

## Summary

Adds an entitlement-gated **AI add-on** column to both the **Users**
table and the **Organization Members** table. When
`ai_governance_user_limit` is entitled, each row shows whether the user
is consuming an AI seat.

## Background

The AI governance add-on tracks which users are consuming AI seats.
Admins need visibility into per-user seat consumption directly from the
user management tables. This change surfaces that information through
both the site-wide Users table and the per-organization Members table,
gated behind the `ai_governance_user_limit` entitlement so the column
only appears when the feature is licensed.

## Implementation

### Backend
- **New SQL query** `GetUserAISeatStates`
(`coderd/database/queries/aiseatstate.sql`) — returns user IDs consuming
an AI seat, derived from:
  - Users with entries in `aibridge_interceptions` (AI Bridge usage)
- Users who own workspaces with `has_ai_task = true` builds (AI Tasks
usage)
- **SDK types** — added `has_ai_seat: boolean` to `codersdk.User` and
`codersdk.OrganizationMemberWithUserData`
- **Handler wiring** — both the Users list endpoint (`coderd/users.go`)
and all Members endpoints (`coderd/members.go`) query AI seat state per
page of user IDs and populate the response field
- **dbauthz** — per-user `ActionRead` checks on `ResourceUserObject`

### Frontend
- **Shared `AISeatCell` component**
(`site/src/modules/users/AISeatCell.tsx`) — green `CircleCheck` for
consuming, gray `X` for non-consuming
- **`TableColumnHelpTooltip`** — extended with `ai_addon` variant with
tooltip: *"Users with access to AI features like AI Bridge, Boundary, or
Tasks who are actively consuming a seat."*
- **Column visibility** gated behind
`useFeatureVisibility().ai_governance_user_limit`

## Validation

- Backend: dbauthz full method suite (`TestMethodTestSuite`) passes
including new `GetUserAISeatStates` test
- Backend: `TestGetUsers`, `TestUsersFilter`, CLI golden file tests pass
- Frontend: 7/7 tests pass across `UsersPage.test.tsx` and
`OrganizationMembersPage.test.tsx` (column visibility gating both
directions)
- `go build ./coderd/...` compiles clean
- `pnpm --dir site run lint:types` passes
- `make gen` clean

## Risks

- **Pagination performance**: The AI seat query is scoped to the current
page's user IDs (not a full table scan), keeping it efficient for
paginated views.
- **Semantic scope**: The workspace-side AI seat derivation uses "any
build with `has_ai_task = true`" rather than "latest build only". If the
product intent is latest-build-only, this can be tightened in a
follow-up.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking:
`xhigh` • Cost: `$27.25`_

<!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh
costs=27.25 -->
This commit is contained in:
Jaayden Halko
2026-03-26 10:36:40 +00:00
committed by GitHub
parent 09d2588e2a
commit 3fb7c6264f
38 changed files with 707 additions and 161 deletions
+12
View File
@@ -17426,6 +17426,10 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"is_service_account": {
"type": "boolean"
},
@@ -20222,6 +20226,10 @@ const docTemplate = `{
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -21071,6 +21079,10 @@ const docTemplate = `{
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+12
View File
@@ -15851,6 +15851,10 @@
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"is_service_account": {
"type": "boolean"
},
@@ -18547,6 +18551,10 @@
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -19339,6 +19347,10 @@
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+7
View File
@@ -3921,6 +3921,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
return q.db.GetUnexpiredLicenses(ctx)
}
func (q *querier) GetUserAISeatStates(ctx context.Context, userIDs []uuid.UUID) ([]uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err
}
return q.db.GetUserAISeatStates(ctx, userIDs)
}
func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
// Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms.
if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil {
+8
View File
@@ -2173,6 +2173,14 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().GetQuotaConsumedForUser(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetUserAISeatStates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
a := testutil.Fake(s.T(), faker, database.User{})
b := testutil.Fake(s.T(), faker, database.User{})
ids := []uuid.UUID{a.ID, b.ID}
seatStates := []uuid.UUID{a.ID}
dbm.EXPECT().GetUserAISeatStates(gomock.Any(), ids).Return(seatStates, nil).AnyTimes()
check.Args(ids).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(seatStates)
}))
s.Run("GetUserByEmailOrUsername", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.GetUserByEmailOrUsernameParams{Email: u.Email}
@@ -2448,6 +2448,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database
return r0, r1
}
func (m queryMetricsStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) {
start := time.Now()
r0, r1 := m.s.GetUserAISeatStates(ctx, userIds)
m.queryLatencies.WithLabelValues("GetUserAISeatStates").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAISeatStates").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserActivityInsights(ctx, arg)
+15
View File
@@ -4578,6 +4578,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx)
}
// GetUserAISeatStates mocks base method.
func (m *MockStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserAISeatStates", ctx, userIds)
ret0, _ := ret[0].([]uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserAISeatStates indicates an expected call of GetUserAISeatStates.
func (mr *MockStoreMockRecorder) GetUserAISeatStates(ctx, userIds any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAISeatStates", reflect.TypeOf((*MockStore)(nil).GetUserAISeatStates), ctx, userIds)
}
// GetUserActivityInsights mocks base method.
func (m *MockStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
m.ctrl.T.Helper()
+4
View File
@@ -548,6 +548,10 @@ type sqlcQuerier interface {
// inclusive.
GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
// Returns user IDs from the provided list that are consuming an AI seat.
// Filters to active, non-deleted, non-system users to match the canonical
// seat count query (GetActiveAISeatCount).
GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error)
// GetUserActivityInsights returns the ranking with top active users.
// The result can be filtered on template_ids, meaning only user data
// from workspaces based on those templates will be included.
+42
View File
@@ -1600,6 +1600,48 @@ func (q *sqlQuerier) UpsertAISeatState(ctx context.Context, arg UpsertAISeatStat
return is_new, err
}
const getUserAISeatStates = `-- name: GetUserAISeatStates :many
SELECT
ais.user_id
FROM
ai_seat_state ais
JOIN
users u
ON
ais.user_id = u.id
WHERE
ais.user_id = ANY($1::uuid[])
AND u.status = 'active'::user_status
AND u.deleted = false
AND u.is_system = false
`
// Returns user IDs from the provided list that are consuming an AI seat.
// Filters to active, non-deleted, non-system users to match the canonical
// seat count query (GetActiveAISeatCount).
func (q *sqlQuerier) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, getUserAISeatStates, pq.Array(userIds))
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var user_id uuid.UUID
if err := rows.Scan(&user_id); err != nil {
return nil, err
}
items = append(items, user_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec
DELETE FROM
api_keys
+17
View File
@@ -0,0 +1,17 @@
-- name: GetUserAISeatStates :many
-- Returns user IDs from the provided list that are consuming an AI seat.
-- Filters to active, non-deleted, non-system users to match the canonical
-- seat count query (GetActiveAISeatCount).
SELECT
ais.user_id
FROM
ai_seat_state ais
JOIN
users u
ON
ais.user_id = u.id
WHERE
ais.user_id = ANY(@user_ids::uuid[])
AND u.status = 'active'::user_status
AND u.deleted = false
AND u.is_system = false;
+62 -4
View File
@@ -2,6 +2,7 @@ package coderd
import (
"context"
"database/sql"
"fmt"
"net/http"
@@ -179,7 +180,17 @@ func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) {
return
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows)
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, []uuid.UUID{member.UserID})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -227,7 +238,21 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) {
return
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members)
userIDs := make([]uuid.UUID, 0, len(members))
for _, member := range members {
userIDs = append(userIDs, member.OrganizationMember.UserID)
}
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -324,7 +349,21 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
return
}
members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows)
userIDs := make([]uuid.UUID, 0, len(memberRows))
for _, member := range memberRows {
userIDs = append(userIDs, member.OrganizationMember.UserID)
}
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -337,6 +376,23 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func getAISeatSetByUserIDs(ctx context.Context, db database.Store, userIDs []uuid.UUID) (map[uuid.UUID]struct{}, error) {
aiSeatUserIDs, err := db.GetUserAISeatStates(ctx, userIDs)
if xerrors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
return nil, err
}
aiSeatSet := make(map[uuid.UUID]struct{}, len(aiSeatUserIDs))
for _, uid := range aiSeatUserIDs {
aiSeatSet[uid] = struct{}{}
}
return aiSeatSet, nil
}
// @Summary Assign role to organization member
// @ID assign-role-to-organization-member
// @Security CoderSessionToken
@@ -508,7 +564,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
return converted, nil
}
func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithUserData, error) {
func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow, aiSeatSet map[uuid.UUID]struct{}) ([]codersdk.OrganizationMemberWithUserData, error) {
members := make([]database.OrganizationMember, 0)
for _, row := range rows {
members = append(members, row.OrganizationMember)
@@ -524,12 +580,14 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto
converted := make([]codersdk.OrganizationMemberWithUserData, 0)
for i := range convertedMembers {
_, hasAISeat := aiSeatSet[rows[i].OrganizationMember.UserID]
converted = append(converted, codersdk.OrganizationMemberWithUserData{
Username: rows[i].Username,
AvatarURL: rows[i].AvatarURL,
Name: rows[i].Name,
Email: rows[i].Email,
GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles),
HasAISeat: hasAISeat,
LastSeenAt: rows[i].LastSeenAt,
Status: codersdk.UserStatus(rows[i].Status),
IsServiceAccount: rows[i].IsServiceAccount,
+70 -8
View File
@@ -329,8 +329,31 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs
}
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
var aiSeatUserIDs []uuid.UUID
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatUserIDs, err = api.Database.GetUserAISeatStates(dbauthz.AsSystemRestricted(ctx), userIDs)
if err != nil {
if !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Warn(
ctx,
"failed to fetch AI seat states for users",
slog.F("user_count", len(userIDs)),
slog.Error(err),
)
}
aiSeatUserIDs = nil
}
aiSeatSet = make(map[uuid.UUID]struct{}, len(aiSeatUserIDs))
for _, uid := range aiSeatUserIDs {
aiSeatSet[uid] = struct{}{}
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GetUsersResponse{
Users: convertUsers(users, organizationIDsByUserID),
Users: convertUsers(users, organizationIDsByUserID, aiSeatSet),
Count: int(userCount),
})
}
@@ -596,7 +619,9 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
Users: []telemetry.User{telemetry.ConvertUser(user)},
})
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.User(user, req.OrganizationIDs))
sdkUser := db2sdk.User(user, req.OrganizationIDs)
api.enrichUserAISeat(ctx, &sdkUser)
httpapi.Write(ctx, rw, http.StatusCreated, sdkUser)
}
// @Summary Delete user
@@ -724,7 +749,9 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs))
sdkUser := db2sdk.User(user, organizationIDs)
api.enrichUserAISeat(ctx, &sdkUser)
httpapi.Write(ctx, rw, http.StatusOK, sdkUser)
}
// Returns recent build parameters for the signed-in user.
@@ -897,7 +924,9 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUserProfile, organizationIDs))
sdkUser := db2sdk.User(updatedUserProfile, organizationIDs)
api.enrichUserAISeat(ctx, &sdkUser)
httpapi.Write(ctx, rw, http.StatusOK, sdkUser)
}
// @Summary Suspend user account
@@ -998,7 +1027,9 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations))
sdkUser := db2sdk.User(targetUser, organizations)
api.enrichUserAISeat(ctx, &sdkUser)
httpapi.Write(ctx, rw, http.StatusOK, sdkUser)
}
}
@@ -1487,7 +1518,9 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs))
sdkUser := db2sdk.User(updatedUser, organizationIDs)
api.enrichUserAISeat(ctx, &sdkUser)
httpapi.Write(ctx, rw, http.StatusOK, sdkUser)
}
// Returns organizations the parameterized user has access to.
@@ -1701,11 +1734,40 @@ func findUserAdmins(ctx context.Context, store database.Store) ([]database.GetUs
return userAdmins, nil
}
func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User {
// enrichUserAISeat sets HasAISeat on the user when the feature is entitled.
func (api *API) enrichUserAISeat(ctx context.Context, user *codersdk.User) {
if !api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
return
}
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatUserIDs, err := api.Database.GetUserAISeatStates(
dbauthz.AsSystemRestricted(ctx),
[]uuid.UUID{user.ID},
)
if err != nil {
if !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Warn(
ctx,
"failed to fetch AI seat state for user",
slog.F("user_id", user.ID),
slog.Error(err),
)
}
return
}
user.HasAISeat = len(aiSeatUserIDs) > 0
}
func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID, aiSeatSet map[uuid.UUID]struct{}) []codersdk.User {
converted := make([]codersdk.User, 0, len(users))
for _, u := range users {
userOrganizationIDs := organizationIDsByUserID[u.ID]
converted = append(converted, db2sdk.User(u, userOrganizationIDs))
_, hasAISeat := aiSeatSet[u.ID]
convertedUser := db2sdk.User(u, userOrganizationIDs)
convertedUser.HasAISeat = hasAISeat
converted = append(converted, convertedUser)
}
return converted
}