From f39b612bd822b68a45d65586a6c37c5907d77d94 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 22 Apr 2026 07:30:22 +0000 Subject: [PATCH] fix(coderd/database/dbauthz): grant AsAIBridged ResourceSystem.ActionCreate for UpsertAISeatState The aibridged RBAC subject was missing ResourceSystem.ActionCreate, causing UpsertAISeatState to fail with 'unauthorized: rbac: forbidden' on the first AI Bridge connection per user. The seat tracker's throttle logic then suppressed retries for 30 minutes, making the error appear one-time. Add the minimal permission and a regression test that exercises the production call path (dbauthz-wrapped DB + AsAIBridged context). Fixes coder/internal#1444 --- coderd/database/dbauthz/dbauthz.go | 1 + enterprise/aiseats/tracker_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f9bb74edc95c7..0278eb266c1ea 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -622,6 +622,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. }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/enterprise/aiseats/tracker_test.go b/enterprise/aiseats/tracker_test.go index cbebd7a07728b..c6f2750eb40dc 100644 --- a/enterprise/aiseats/tracker_test.go +++ b/enterprise/aiseats/tracker_test.go @@ -5,14 +5,19 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" agplaiseats "github.com/coder/coder/v2/coderd/aiseats" "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "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" enterpriseaiseats "github.com/coder/coder/v2/enterprise/aiseats" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -37,6 +42,38 @@ func TestSeatTrackerDB(t *testing.T) { require.EqualValues(t, 1, count) }) + // Regression test for coder/internal#1444: UpsertAISeatState must + // succeed when called through the AsAIBridged RBAC subject. The + // aibridged daemon context was missing ResourceSystem.ActionCreate, + // which caused the very first RecordUsage call per user to fail + // with "unauthorized: rbac: forbidden". + t.Run("AsAIBridgedRBAC", func(t *testing.T) { + t.Parallel() + + rawDB, _ := dbtestutil.NewDB(t) + authz := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + authzDB := dbauthz.New(rawDB, authz, slogtest.Make(t, nil), coderdtest.AccessControlStorePointer()) + + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + tracker := enterpriseaiseats.New(authzDB, testutil.Logger(t), clock, nil) + + // Insert a user directly in the raw DB so it exists for the + // foreign key reference. + user := dbgen.User(t, rawDB, database.User{Status: database.UserStatusActive}) + + // Call RecordUsage with the AIBridged context, mirroring the + // production call path in aibridgedserver.RecordInterception. + aibridgedCtx := dbauthz.AsAIBridged(ctx) + tracker.RecordUsage(aibridgedCtx, user.ID, agplaiseats.ReasonAIBridge("provider=test, model=test")) + + // Verify the seat was actually recorded. A count of 0 means + // the upsert was silently rejected by RBAC. + count, err := rawDB.GetActiveAISeatCount(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, count, "AI seat should be recorded when using AsAIBridged context") + }) + t.Run("InactiveUsersExcluded", func(t *testing.T) { t.Parallel()