feat: migrate agents-access to org-scoped system role for proper chat RBAC (#24438)

The agents-access role previously granted chat permissions at user
scope, but chats are org-scoped objects. Rego skips user-level perms
when org_owner is set, making the grants invisible. Handler-level
band-aids used synthetic non-org-scoped objects as a workaround.

  - Migrates agents-access from users.rbac_roles (site-level) to
    organization_members.roles (org-scoped) via DB migration
  - Redefines agents-access as a predefined org-scoped builtin role
    alongside organization-admin, organization-auditor, etc., with
    Member permissions granting chat create/read/update
  - Excludes ResourceChat from OrgMemberPermissions so org membership
    alone no longer grants chat access
  - Fixes handler Authorize checks to use org-scoped objects with
semantically correct actions (ActionUpdate for message/tool operations)
  - Grants org admins the ability to assign agents-access

Closes #24250
Fixes CODAGT-174

Note: this does not update the "Usage" endpoints. Tracked by CODAGT-161.
> 🤖
This commit is contained in:
Cian Johnston
2026-04-23 17:59:42 +01:00
committed by GitHub
parent 7efccfa996
commit b5a625549e
19 changed files with 441 additions and 174 deletions
+24 -28
View File
@@ -524,11 +524,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use
// from large dynamic tool schemas.
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
@@ -568,6 +563,14 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
})
return
}
// NOTE: This authorize check is intentionally placed after request
// parsing because we need req.OrganizationID to scope the RBAC check
// to the correct org. The request body is bounded by MaxBytesReader
// above, limiting the cost of parsing before rejection.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(req.OrganizationID)) {
httpapi.Forbidden(rw)
return
}
// Validate per-chat system prompt length.
const maxSystemPromptLen = 10000
@@ -2344,14 +2347,9 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
chat := httpmw.ChatParam(r)
chatID := chat.ID
// Gate message sending behind the same agents-access check
// used by postChats. Sending a message triggers AI/LLM
// inference, so it should require the same authorization as
// chat creation. This is a handler-level band-aid; the
// structural fix is to make agents-access org-aware so
// dbauthz enforces this at the RBAC layer.
// See: https://github.com/coder/coder/issues/24250
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
// Sending a message triggers LLM inference, requiring update
// permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
return
}
@@ -2640,11 +2638,9 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
chat := httpmw.ChatParam(r)
chatID := chat.ID
// Gate queued-message promotion behind agents-access.
// Promoting a queued message triggers AI/LLM inference,
// same as sending a new message.
// See: https://github.com/coder/coder/issues/24250
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
// Promoting a queued message triggers LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
return
}
@@ -4567,11 +4563,6 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
orgIDStr := r.URL.Query().Get("organization")
if orgIDStr == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -4586,6 +4577,13 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
})
return
}
// NOTE: This authorize check is intentionally placed after query
// parameter parsing because we need orgID to scope the RBAC check
// to the correct org.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(orgID)) {
httpapi.Forbidden(rw)
return
}
contentType := r.Header.Get("Content-Type")
if contentType == "" {
@@ -6754,11 +6752,9 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
chat := httpmw.ChatParam(r)
apiKey := httpmw.APIKey(r)
// Gate tool-result submission behind agents-access.
// Submitting tool results resumes AI/LLM inference on
// a chat in requires_action state.
// See: https://github.com/coder/coder/issues/24250
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
// Submitting tool results resumes LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.Forbidden(rw)
return
}