chore: add aibridge database resources & define RBAC policies (#19796)

Closes https://github.com/coder/internal/issues/986
This commit is contained in:
Danny Kopping
2025-09-16 21:31:17 +02:00
committed by GitHub
parent 348a2e0285
commit 422bba44d9
26 changed files with 770 additions and 0 deletions
+77
View File
@@ -175,6 +175,22 @@ func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.
return xerrors.Errorf("authorize context: %w", workspaceErr)
}
// authorizeAIBridgeInterceptionUpdate validates that the context's actor matches the initiator of the AIBridgeInterception.
// This is used by all of the sub-resources which fall under the [ResourceAibridgeInterception] umbrella.
func (q *querier) authorizeAIBridgeInterceptionUpdate(ctx context.Context, interceptionID uuid.UUID) error {
inter, err := q.db.GetAIBridgeInterceptionByID(ctx, interceptionID)
if err != nil {
return xerrors.Errorf("fetch aibridge interception %q: %w", interceptionID, err)
}
err = q.authorizeContext(ctx, policy.ActionUpdate, inter.RBACObject())
if err != nil {
return err
}
return nil
}
type authContextKey struct{}
// ActorFromContext returns the authorization subject from the context.
@@ -542,6 +558,29 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
// See aibridged package.
subjectAibridged = rbac.Subject{
Type: rbac.SubjectAibridged,
FriendlyName: "AIBridge Daemon",
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "aibridged"},
DisplayName: "AIBridge Daemon",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceUser.Type: {
policy.ActionReadPersonal, // Required to read users' external auth links. // TODO: this is too broad; reduce scope to just external_auth_links by creating separate resource.
},
rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys.
rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)
// AsProvisionerd returns a context with an actor that has permissions required
@@ -624,6 +663,12 @@ func AsUsagePublisher(ctx context.Context) context.Context {
return As(ctx, subjectUsagePublisher)
}
// AsAIBridged returns a context with an actor that has permissions
// required for creating, reading, and updating aibridge-related resources.
func AsAIBridged(ctx context.Context) context.Context {
return As(ctx, subjectAibridged)
}
var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
@@ -1878,6 +1923,10 @@ func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMat
return q.db.FindMatchingPresetID(ctx, arg)
}
func (q *querier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) {
return fetch(q.log, q.auth, q.db.GetAIBridgeInterceptionByID)(ctx, id)
}
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
}
@@ -3757,6 +3806,34 @@ func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now ti
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
}
func (q *querier) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) {
return insert(q.log, q.auth, rbac.ResourceAibridgeInterception.WithOwner(arg.InitiatorID.String()), q.db.InsertAIBridgeInterception)(ctx, arg)
}
func (q *querier) InsertAIBridgeTokenUsage(ctx context.Context, arg database.InsertAIBridgeTokenUsageParams) error {
// All aibridge_token_usages records belong to the initiator of their associated interception.
if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil {
return err
}
return q.db.InsertAIBridgeTokenUsage(ctx, arg)
}
func (q *querier) InsertAIBridgeToolUsage(ctx context.Context, arg database.InsertAIBridgeToolUsageParams) error {
// All aibridge_tool_usages records belong to the initiator of their associated interception.
if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil {
return err
}
return q.db.InsertAIBridgeToolUsage(ctx, arg)
}
func (q *querier) InsertAIBridgeUserPrompt(ctx context.Context, arg database.InsertAIBridgeUserPromptParams) error {
// All aibridge_user_prompts records belong to the initiator of their associated interception.
if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil {
return err
}
return q.db.InsertAIBridgeUserPrompt(ctx, arg)
}
func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
// TODO(Cian): ideally this would be encoded in the policy, but system users are just members and we
// don't currently have a capability to conditionally deny creating resources by owner ID in a role.
+52
View File
@@ -4332,3 +4332,55 @@ func TestInsertAPIKey_AsPrebuildsUser(t *testing.T) {
_, err := dbz.InsertAPIKey(ctx, testutil.Fake(t, faker, database.InsertAPIKeyParams{}))
require.True(t, dbauthz.IsNotAuthorizedError(err))
}
func (s *MethodTestSuite) TestAIBridge() {
s.Run("GetAIBridgeInterceptionByID", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
sessID := uuid.UUID{2}
sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID})
db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes()
check.Args(sessID).Asserts(sess, policy.ActionRead).Returns(sess)
}))
s.Run("InsertAIBridgeInterception", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
initID := uuid.UUID{3}
user := testutil.Fake(s.T(), faker, database.User{ID: initID})
// testutil.Fake cannot distinguish between a zero value and an explicitly requested value which is equivalent.
user.IsSystem = false
user.Deleted = false
sessID := uuid.UUID{2}
sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID, InitiatorID: initID})
params := database.InsertAIBridgeInterceptionParams{ID: sess.ID, InitiatorID: sess.InitiatorID, Provider: sess.Provider, Model: sess.Model}
db.EXPECT().GetUserByID(gomock.Any(), initID).Return(user, nil).AnyTimes() // Validation.
db.EXPECT().InsertAIBridgeInterception(gomock.Any(), params).Return(sess, nil).AnyTimes()
check.Args(params).Asserts(sess, policy.ActionCreate)
}))
s.Run("InsertAIBridgeTokenUsage", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
sessID := uuid.UUID{2}
sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID})
params := database.InsertAIBridgeTokenUsageParams{InterceptionID: sess.ID}
db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation.
db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), params).Return(nil).AnyTimes()
check.Args(params).Asserts(sess, policy.ActionUpdate)
}))
s.Run("InsertAIBridgeToolUsage", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
sessID := uuid.UUID{2}
sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID})
params := database.InsertAIBridgeToolUsageParams{InterceptionID: sess.ID}
db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation.
db.EXPECT().InsertAIBridgeToolUsage(gomock.Any(), params).Return(nil).AnyTimes()
check.Args(params).Asserts(sess, policy.ActionUpdate)
}))
s.Run("InsertAIBridgeUserPrompt", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
sessID := uuid.UUID{2}
sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID})
params := database.InsertAIBridgeUserPromptParams{InterceptionID: sess.ID}
db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation.
db.EXPECT().InsertAIBridgeUserPrompt(gomock.Any(), params).Return(nil).AnyTimes()
check.Args(params).Asserts(sess, policy.ActionUpdate)
}))
}