fix: introduce ResourceAiSeat for fine-grained AI seat RBAC (#24613)

Fixes: https://github.com/coder/internal/issues/1444
This commit is contained in:
Marcin Tojek
2026-04-30 12:29:35 +02:00
committed by GitHub
parent dbb50ebaaf
commit f993b72628
22 changed files with 207 additions and 70 deletions
+8
View File
@@ -13802,6 +13802,9 @@ const docTemplate = `{
"enum": [
"all",
"application_connect",
"ai_seat:*",
"ai_seat:create",
"ai_seat:read",
"aibridge_interception:*",
"aibridge_interception:create",
"aibridge_interception:read",
@@ -14011,6 +14014,9 @@ const docTemplate = `{
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiSeatAll",
"APIKeyScopeAiSeatCreate",
"APIKeyScopeAiSeatRead",
"APIKeyScopeAibridgeInterceptionAll",
"APIKeyScopeAibridgeInterceptionCreate",
"APIKeyScopeAibridgeInterceptionRead",
@@ -19483,6 +19489,7 @@ const docTemplate = `{
"type": "string",
"enum": [
"*",
"ai_seat",
"aibridge_interception",
"api_key",
"assign_org_role",
@@ -19529,6 +19536,7 @@ const docTemplate = `{
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAiSeat",
"ResourceAibridgeInterception",
"ResourceApiKey",
"ResourceAssignOrgRole",
+8
View File
@@ -12342,6 +12342,9 @@
"enum": [
"all",
"application_connect",
"ai_seat:*",
"ai_seat:create",
"ai_seat:read",
"aibridge_interception:*",
"aibridge_interception:create",
"aibridge_interception:read",
@@ -12551,6 +12554,9 @@
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiSeatAll",
"APIKeyScopeAiSeatCreate",
"APIKeyScopeAiSeatRead",
"APIKeyScopeAibridgeInterceptionAll",
"APIKeyScopeAibridgeInterceptionCreate",
"APIKeyScopeAibridgeInterceptionRead",
@@ -17825,6 +17831,7 @@
"type": "string",
"enum": [
"*",
"ai_seat",
"aibridge_interception",
"api_key",
"assign_org_role",
@@ -17871,6 +17878,7 @@
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAiSeat",
"ResourceAibridgeInterception",
"ResourceApiKey",
"ResourceAssignOrgRole",
+6 -4
View File
@@ -226,6 +226,7 @@ var (
rbac.ResourceProvisionerJobs.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreate},
rbac.ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState via SeatTracker.
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
// Unsure why provisionerd needs update and read personal
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
@@ -597,6 +598,7 @@ var (
DisplayName: "Usage Publisher",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceLicense.Type: {policy.ActionRead},
rbac.ResourceAiSeat.Type: {policy.ActionRead}, // Required for GetActiveAISeatCount.
// The usage publisher doesn't create events, just
// reads/processes them.
rbac.ResourceUsageEvent.Type: {policy.ActionRead, policy.ActionUpdate},
@@ -624,7 +626,7 @@ var (
},
rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys.
rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceSystem.Type: {policy.ActionCreate}, // Required for UpsertAISeatState.
rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState.
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
@@ -2470,7 +2472,7 @@ func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Tim
}
func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiSeat); err != nil {
return 0, err
}
return q.db.GetActiveAISeatCount(ctx)
@@ -4228,7 +4230,7 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
}
func (q *querier) GetUserAISeatStates(ctx context.Context, userIDs []uuid.UUID) ([]uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiSeat); err != nil {
return nil, err
}
return q.db.GetUserAISeatStates(ctx, userIDs)
@@ -7376,7 +7378,7 @@ func (q *querier) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg datab
}
func (q *querier) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAiSeat); err != nil {
return false, err
}
return q.db.UpsertAISeatState(ctx, arg)
+7 -4
View File
@@ -1848,15 +1848,18 @@ func (s *MethodTestSuite) TestProvisionerJob() {
}))
}
func (s *MethodTestSuite) TestLicense() {
func (s *MethodTestSuite) TestAISeat() {
s.Run("GetActiveAISeatCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(100), nil).AnyTimes()
check.Args().Asserts(rbac.ResourceLicense, policy.ActionRead).Returns(int64(100))
check.Args().Asserts(rbac.ResourceAiSeat, policy.ActionRead).Returns(int64(100))
}))
s.Run("UpsertAISeatState", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertAISeatState(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes()
check.Args(database.UpsertAISeatStateParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
check.Args(database.UpsertAISeatStateParams{}).Asserts(rbac.ResourceAiSeat, policy.ActionCreate)
}))
}
func (s *MethodTestSuite) TestLicense() {
s.Run("GetLicenses", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
a := database.License{ID: 1}
b := database.License{ID: 2}
@@ -2544,7 +2547,7 @@ func (s *MethodTestSuite) TestUser() {
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)
check.Args(ids).Asserts(rbac.ResourceAiSeat, 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{})
+4 -1
View File
@@ -220,7 +220,10 @@ CREATE TYPE api_key_scope AS ENUM (
'chat:read',
'chat:update',
'chat:delete',
'chat:*'
'chat:*',
'ai_seat:*',
'ai_seat:create',
'ai_seat:read'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -0,0 +1,2 @@
-- These enum values cannot be removed from PostgreSQL.
-- This migration is a no-op placeholder for rollback safety.
@@ -0,0 +1,3 @@
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_seat:*';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_seat:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_seat:read';
+10 -1
View File
@@ -224,6 +224,9 @@ const (
ApiKeyScopeChatUpdate APIKeyScope = "chat:update"
ApiKeyScopeChatDelete APIKeyScope = "chat:delete"
ApiKeyScopeChat APIKeyScope = "chat:*"
ApiKeyScopeAiSeat APIKeyScope = "ai_seat:*"
ApiKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create"
ApiKeyScopeAiSeatRead APIKeyScope = "ai_seat:read"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -467,7 +470,10 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeChatRead,
ApiKeyScopeChatUpdate,
ApiKeyScopeChatDelete,
ApiKeyScopeChat:
ApiKeyScopeChat,
ApiKeyScopeAiSeat,
ApiKeyScopeAiSeatCreate,
ApiKeyScopeAiSeatRead:
return true
}
return false
@@ -680,6 +686,9 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeChatUpdate,
ApiKeyScopeChatDelete,
ApiKeyScopeChat,
ApiKeyScopeAiSeat,
ApiKeyScopeAiSeatCreate,
ApiKeyScopeAiSeatRead,
}
}
+9
View File
@@ -15,6 +15,14 @@ var (
Type: "*",
}
// ResourceAiSeat
// Valid Actions
// - "ActionCreate" :: record AI seat usage
// - "ActionRead" :: read AI seat state
ResourceAiSeat = Object{
Type: "ai_seat",
}
// ResourceAibridgeInterception
// Valid Actions
// - "ActionCreate" :: create aibridge interceptions & related records
@@ -433,6 +441,7 @@ var (
func AllResources() []Objecter {
return []Objecter{
ResourceWildcard,
ResourceAiSeat,
ResourceAibridgeInterception,
ResourceApiKey,
ResourceAssignOrgRole,
+6
View File
@@ -392,6 +392,12 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionCreate: "create aibridge interceptions & related records",
},
},
"ai_seat": {
Actions: map[Action]ActionDefinition{
ActionCreate: "record AI seat usage",
ActionRead: "read AI seat state",
},
},
"boundary_usage": {
Actions: map[Action]ActionDefinition{
ActionRead: "read boundary usage statistics",
+3 -3
View File
@@ -294,7 +294,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Workspace dormancy and workspace are omitted.
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
// Owners cannot access other users' secrets.
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUsageEvent, ResourceBoundaryUsage),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat),
// This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions,
@@ -322,7 +322,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
@@ -454,7 +454,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Org admins should not have workspace exec perms.
organizationID.String(): {
Org: append(
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage),
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceAiSeat),
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
+8
View File
@@ -1104,6 +1104,14 @@ func TestRolePermissions(t *testing.T) {
false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
Name: "AiSeat",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead},
Resource: rbac.ResourceAiSeat,
AuthorizeMap: map[bool][]hasAuthSubjects{
false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
Name: "ChatUsageCRU",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
+6
View File
@@ -7,6 +7,8 @@ package rbac
// declared in code, not here, to avoid duplication.
const (
ScopeAiSeatCreate ScopeName = "ai_seat:create"
ScopeAiSeatRead ScopeName = "ai_seat:read"
ScopeAibridgeInterceptionCreate ScopeName = "aibridge_interception:create"
ScopeAibridgeInterceptionRead ScopeName = "aibridge_interception:read"
ScopeAibridgeInterceptionUpdate ScopeName = "aibridge_interception:update"
@@ -171,6 +173,8 @@ func (e ScopeName) Valid() bool {
case ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAiSeatCreate,
ScopeAiSeatRead,
ScopeAibridgeInterceptionCreate,
ScopeAibridgeInterceptionRead,
ScopeAibridgeInterceptionUpdate,
@@ -336,6 +340,8 @@ func AllScopeNameValues() []ScopeName {
ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAiSeatCreate,
ScopeAiSeatRead,
ScopeAibridgeInterceptionCreate,
ScopeAibridgeInterceptionRead,
ScopeAibridgeInterceptionUpdate,