Add the `chat_model_config` RBAC resource, actions, and API key scopes.
This layer includes the scope migration and generated API types. Model
list queries apply token scopes, SQL authorization filters, and row
ACLs.
Write and default-management handlers keep deployment configuration
authorization until the next API layer replaces it.
Depends on #27956
_This pull request description was generated by Coder Agents._
Re-gates every `mcp_server_user_tokens` dbauthz method from
`rbac.ResourceDeploymentConfig` onto owner-scoped personal actions,
mirroring the `external_auth_links` prior art. These are per-user
credential rows, not deployment config, and the old gate forced every
call site to elevate with `dbauthz.AsSystemRestricted`.
## Summary
- Reads gate on `ActionReadPersonal` and writes on
`ActionUpdatePersonal` against the token owner
(`MCPServerUserToken.RBACObject()` derives from `user_id`). The id-keyed
refresh and refresh-failure mutations resolve the owner via a
`GetMCPServerUserTokenByID` prefetch inside dbauthz; the mutation
queries cannot retarget a row's owner, so there is no fetch-authorize
race.
- All seven token-path `AsSystemRestricted` elevations in
`coderd/mcp.go` and all four in chatd are removed along with their
`//nolint:gocritic` markers; handlers use the request actor.
- The daemon-wide chatd subject keeps only `ActionReadPersonal` on
`ResourceUser`. Background token refresh and failure persistence use a
new per-user `AsChatdTokenOwner` subject holding only user-level
`ActionUpdatePersonal` scoped to the token owner (both mutations resolve
the row via the underlying store, so the subject needs no read grant),
and chatd never holds site-wide personal-write access. `TestAsChatd`
pins the complete `ResourceUser` action set and
`TestMCPServerUserTokensAuth` exercises both subjects against the real
authorizer.
- New strict-authorizer test (`rbac.NewAuthorizer` against real rows):
owner passes get/upsert/delete, a stranger fails, and an org admin of
another org fails.
No user-visible behavior change. Stacked on #27944. Completes the MCP
org-separation stack.
Closes https://linear.app/codercom/issue/CODAGT-806
UAT: regression pass on a dogfood instance: OAuth2 connect redirect,
member list/status with redaction, self-scoped disconnect (cannot touch
another user's token), end-to-end chat MCP tool invocation, and
lower-stack audit/ACL smoke checks.
> Mux (AI agent) authored this PR on Mike's behalf.
<!-- mux-attribution: model=claude-fable-5 thinking=high -->
`rbacTraceAttributes` materialized the subject's role names (one string
allocation per role) and was passed into every `Filter`, `Authorize`,
and `Prepare` span at creation time, so the O(roles) work ran even when
no tracer was recording. It also called `SafeRoleNames()` twice.
Replace it with `setRBACAttributes`, which attaches the same attributes
*after* the span is created and only when `span.IsRecording()` is true,
reading `SafeRoleNames()` once. Recorded spans are unchanged; untraced
and unsampled calls skip the per-role work.
This originated from #27309: once `/authcheck` checks are batched
through `rbac.Filter`, each below-threshold group paid the
role-attribute build for the `Filter` span *and* for every per-object
`Authorize` span, so the redundant per-call work showed up as extra
allocations per request.
## Benchmarks
`AMD EPYC 9575F`, `benchstat`, no tracer configured (exercises the
`IsRecording()==false` path).
**`BenchmarkRBACManyOrgs`** (general RBAC eval), before vs after: wall
time flat (geomean −0.04%), allocations strictly lower everywhere
(geomean B/op −0.52%; `Authorize` −1.0 to −1.3% B/op), no regressions.
**Authcheck path** (`BenchmarkAuthcheckGrouping`, #27309 vs #27310,
back-to-back): this change is an **allocation reduction and is
time-neutral**. On the endpoint (`Grouped`) path, per-request
allocations drop ~4-5% B/op at common org counts (1-10); on the pure
per-object path the reduction grows with org count (B/op −2.6% → −7.3%
at 100 orgs). Wall time is flat within noise: low-org deltas sit inside
this host's ±10-23% run-to-run variance, so no wall-time claim is made.
Net: same speed, less garbage per request, which also lowers GC pressure
under real concurrent load.
<details>
<summary>Decision log</summary>
- The `Filter` span wraps the whole filtering routine (total latency +
`num_objects`); it is the valuable span and is kept. The costly part was
`rbacTraceAttributes`, not the span itself.
- `rbacTraceAttributes` was O(roles): it allocated a string per role for
the `subject_roles` attribute and called `SafeRoleNames()` twice. On
`Filter`'s below-threshold fallback it ran once for the `Filter` span
and again for each per-object `Authorize` span, so a group of N objects
paid N+1 builds vs the old loop's N. Benchmarks confirm this as real
per-call allocation; its wall-time cost is below the authcheck
benchmark's noise floor.
- Deferring attribute construction behind `IsRecording()` requires the
span object, so the three callsites moved from `StartSpan(ctx,
rbacTraceAttributes(...))` to `StartSpan(ctx)` then
`setRBACAttributes(span, ...)`. No spans were removed or renamed;
recorded output is identical.
- Tradeoff: when a span is not recording,
`subject_roles`/`num_subject_roles`/etc. are not computed. Unsampled
spans emit nothing anyway, so there is no observable output change.
</details>
---
Authored with Coder Agents.
`POST /api/v2/authcheck` evaluated every check with a full policy
evaluation in a serial loop. A subject in many organizations (100+)
produced hundreds of full evaluations, taking seconds on a cold cache
(DEVEX-608).
Group the checks by `(action, resource type)` and authorize each group
with the existing `rbac.Filter`, which amortizes a single partial
evaluation across the group once it is large enough. Each check is
wrapped in a small value struct that carries its response key, so
`Filter`'s returned subset maps back to keys by reading a field rather
than relying on element identity.
`Filter` now takes an explicit `prepareThreshold`; existing callers pass
the new `rbac.DefaultFilterThreshold` (10), and `checkAuthorization`
passes 50, above the ~35-group crossover measured for this workload, so
subjects with few objects of a given type keep the per-object path and
cannot regress.
## Stacking
This is stacked on top of #27244. `Filter` runs `Prepare` (partial
evaluation), and those residuals are only compact once #27244's
set-membership residuals land. On plain `main` the existing O(N)
residual fanout means batching can regress at high org counts, so this
change should land with or after #27244.
<details>
<summary>Decision log</summary>
### Bottleneck
- `site/src/modules/permissions/organizations.ts` defines ~14 permission
checks per org; `organizationsPermissions()` flattens them across all
orgs into one `POST /api/v2/authcheck`. A 100-org request is ~1400
checks.
- `checkAuthorization` looped serially, calling `Authorizer.Authorize`
(full eval) once per check.
- The endpoint's `maxFetch = 10` only caps checks that carry a
`resource_id`, not total checks, so it does not bound this workload.
### Approach
- Group checks by `(action, resource type)` and run each group through
`rbac.Filter`, which does one partial evaluation (`Prepare`) and reuses
it across the group.
- Carry the response key as data in a small value struct implementing
`RBACObject()`, so allowed results map back to keys without pointer
identity:
```go
type authorizeCheck struct {
key string
object rbac.Object
}
func (c authorizeCheck) RBACObject() rbac.Object { return c.object }
```
- `Filter` takes a required `prepareThreshold int` (no functional
options). Generic callers pass `rbac.DefaultFilterThreshold = 10`;
`/authcheck` passes 50 because the measured crossover for this workload
is ~35 groups.
### Alternatives rejected
- **Bounded `errgroup` parallelism**: reduced wall time at high org
counts but not aggregate work (allocations flat). Discarded in favor of
reducing work via partial evaluation.
- **Symmetric-deny Rego simplification** (on the #27244 branch):
replacing the known-org deny-fold with symmetric `org := -1` /
`scope_org := -1` rules failed existing SQL-compile tests. A `-1`
known-org vote gated by `not org = -1` produces a negated membership
test over the unknown org id, which OPA emits as an unconvertible
support rule. #27244's fold (`member_allow - org_deny`, a positive
set-difference membership test) is therefore load-bearing, not
incidental.
</details>
---
Authored with Coder Agents.
---------
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
> Mux is working on behalf of Mike.
## Summary
Add a per-user synthetic API key for chatd AI Gateway attribution. Chatd
resolves the key from the chat owner, extends it before expiry, and
discards the generated bearer token so the key is never a usable
credential.
There is no mapping table. The key is resolved from `api_keys` by a
deterministic token name (`chatd_<owner_id>_session_token`), mirroring
the provisionerd session token model, with three deltas that chatd
needs:
- **Login type guard**: token names are unvalidated user input, so a
user can create a bearer token with the colliding name. The lookup
excludes `login_type = 'token'` rows, so chatd never picks up (or
extends) a real user token. Synthetic keys are minted with the owner's
login type, which is never `token`.
- **In-place expiry extension instead of delete-and-reinsert**: chat
generations have no stop boundary, and an in-flight generation may have
already delegated the current key ID to aibridged. Extending
`expires_at` keeps the key ID stable forever.
- **Advisory-lock mint**: the unique index on token names is partial
(`WHERE login_type = 'token'`), so nothing DB-enforces uniqueness for
synthetic keys. A per-user advisory lock serializes concurrent mints.
Keys carry a minimal scope (`api_key:read`) as defense in depth; the
delegated gateway path never evaluates scopes and the secret is
discarded at mint.
Migration 000544 removes the foreign keys from the legacy message and
queue `api_key_id` columns while chatd continues stamping them for
rolling compatibility. Stale IDs are tolerated because routing uses
`chats.owner_id`. Individual key deletion, delete-all, and password
reset remove the key without changing chat history or queue versions,
and the next lookup remints it. Suspension does not delete the key;
delegated gateway authorization rejects inactive users at request time.
This is the first PR in a three-PR rollout and must be fully deployed
before #27171.
Refs
https://linear.app/codercom/issue/CODAGT-561/maintain-synthetic-api-key-per-user-per-chat
User Admin password resets could update the target user's hashed
password but fail while revoking that user's API keys. The transaction
then rolled back and returned HTTP 500, so the password was never
changed.
Add a user-scoped API key revoker actor and use it in both password
reset flows so key revocation succeeds without broader system auth.
Refs: https://linear.app/codercom/issue/PLAT-316
This change reuses the authenticated subject's existing organization
membership information during chat creation instead of issuing an
`OrganizationMembers` query.
The current query is still correct, so this is not required for
correctness. However, `workspaceapps` already answers the same question
more cheaply from the request's RBAC subject. This extracts that logic
into `rbac.Subject.HasOrganizationMembership` and reuses it in both
places, removing an extra database lookup from chat creation without
changing the authorization behavior.
I'm currently debugging a Coder agents scaletest regression where a run
on April 2, 2026 with 4800 concurrent chat creations passed, while the
same run on April 15, 2026 does not. We could stagger chat creation to
reduce the burst, but I'd rather understand why this bottleneck appeared
in the first place so we can keep making small hot-path improvements
like this one instead of only smoothing over the symptom.
Fixes https://github.com/coder/internal/issues/1436
* Adds organization_id to chats with backfill (workspace org → user org membership → default org)
* No support yet for ACLs (follow-up issue)
- Cross-org workspace binding rejected (both in `CreateChatRequest` and in `create_workspace` tool
- Adds `OrganizationAutocomplete` to `AgentCreateForm`
- Docs updated with `organization_id` in chats-api.md
> 🤖 Written by a Coder Agent. Reviewed by many humans and many agents.
---------
Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
## Problem
The chat listing endpoint (`GetChatsByOwnerID`) was using
`fetchWithPostFilter`, which fetches N rows from the database and then
filters them in Go memory using RBAC checks. This causes a pagination
bug: if the user requests `limit=25` but some rows fail the auth check,
fewer than 25 rows are returned even though more authorized rows exist
in the database. The client may incorrectly assume it has reached the
end of the list.
## Solution
Switch to the same pattern used by `GetWorkspaces`, `GetTemplates`, and
`GetUsers`: `prepareSQLFilter` + `GetAuthorized*` variant. The RBAC
filter is compiled to a SQL WHERE clause and injected into the query
before `ORDER BY`/`LIMIT`, so the database returns exactly the requested
number of authorized rows.
Additionally, `GetChatsByOwnerID` is renamed to `GetChats` with
`OwnerID` as an optional (nullable) filter parameter, matching the
`GetWorkspaces` naming convention.
## Changes
| File | Change |
|------|--------|
| `queries/chats.sql` | Renamed to `GetChats`, `owner_id` now optional
via CASE/NULL, added `-- @authorize_filter` |
| `queries.sql.go` | Renamed constant, params struct (`GetChatsParams`),
and method |
| `querier.go` | Interface method renamed |
| `modelqueries.go` | Added `chatQuerier` interface +
`GetAuthorizedChats` impl |
| `dbauthz/dbauthz.go` | `GetChats` now uses `prepareSQLFilter` instead
of `fetchWithPostFilter` |
| `dbauthz/dbauthz_test.go` | Updated tests for SQL filter pattern |
| `dbmock/dbmock.go` | Renamed + added mock for `GetAuthorizedChats` |
| `dbmetrics/querymetrics.go` | Renamed + added metrics wrapper |
| `rbac/regosql/configs.go` | Added `ChatConverter` (maps `org_owner` to
empty string literal since `chats` has no `organization_id` column) |
| `rbac/authz.go` | Added `ConfigChats()` |
| `chats.go` | Handler uses renamed method with `uuid.NullUUID` |
| `searchquery/search.go` | Updated return type |
| `gitsync/worker.go` | Updated interface and call site |
| Various test files | Updated for renamed types |
Add a new SubjectTypeChatd RBAC subject with minimal permissions:
- Chat: CRUD
- Workspace: Read
- DeploymentConfig: Read
Replace all 10 AsSystemRestricted calls in coderd/chatd/chatd.go:
- Line 890: Use AsChatd instead of AsSystemRestricted for the background
processor context.
- Subscribe() path (5 calls): Remove system escalation entirely; these
run under the authenticated user's context from the HTTP handler.
- processChat path (4 calls): Remove redundant per-call wraps; the
context already carries AsChatd from the processor start.
Add TestAsChatd verifying allowed and denied actions.
Created using Mux (Opus 4.6)
The provisioner state for a workspace build was being loaded for every
long-lived agent rpc connection. Since this state can be anywhere from
kilobytes to megabytes this can gradually cause the `coderd` memory
footprint to grow over time. It's also a lot of unnecessary allocations
for every query that fetches a workspace build since only a few callers
ever actually reference the provisioner state.
This PR removes it from the returned workspace build and adds a query to
fetch the provisioner state explicitly.
feat: add boundary usage telemetry database schema and RBAC
Adds the foundation for tracking boundary usage telemetry across Coder
replicas. This includes:
- Database schema: `boundary_usage_stats` table with per-replica stats
(unique workspaces, unique users, allowed/denied request counts)
- Database queries: upsert stats, get aggregated summary, reset stats,
delete by replica ID
- RBAC: `boundary_usage` resource type with read/update/delete actions,
accessible only via system `BoundaryUsageTracker` subject (not regular
user roles)
- Tracker skeleton + docs: stub implementation in `coderd/boundaryusage/`
The tracker accumulates stats in memory and periodically flushes to the
database. Stats are aggregated across replicas for telemetry reporting,
then reset when a new reporting period begins. The tracker implementation
and plumbing will be done in a subsequent commit/PR.
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Related to
[`internal#1139`](https://github.com/coder/internal/issues/1139)
Continuation of #21074
This implements some RBAC role specificity for `dbpurge`, ensuring that
we follow the least-privileged model for removing data from the
database. It is specified as following.
```go
Site: rbac.Permissions(map[string][]policy.Action{
// DeleteOldWorkspaceAgentLogs
// DeleteOldWorkspaceAgentStats
// DeleteOldProvisionerDaemons
// DeleteOldTelemetryLocks
// DeleteOldAuditLogConnectionEvents
// DeleteOldConnectionLogs
rbac.ResourceSystem.Type: {policy.ActionDelete},
// DeleteOldNotificationMessages
rbac.ResourceNotificationMessage.Type: {policy.ActionDelete},
// ExpirePrebuildsAPIKeys
// DeleteExpiredAPIKeys
rbac.ResourceApiKey.Type: {policy.ActionDelete},
// DeleteOldAIBridgeRecords
rbac.ResourceAibridgeInterception.Type: {policy.ActionDelete},
}),
```
| Position | Pull-request |
| -------- | ------------ |
| | [feat: add prometheus observability metrics for
`dbpurge`](https://github.com/coder/coder/pull/21074) |
| ✅ | [feat: add rbac specificity for
`dbpurge`](https://github.com/coder/coder/pull/21088) |
Not used in coderd yet, see stack.
Adds two new packages:
- `coderd/usage`: provides an interface for the "Collector" as well as a stub implementation for AGPL
- `enterprise/coderd/usage`: provides an interface for the "Publisher" as well as a Tallyman implementation
Relates to https://github.com/coder/internal/issues/814
### Breaking Change (changelog note):
> User connections to workspaces, and the opening of workspace apps or ports will no longer create entries in the audit log. Those events will now be included in the 'Connection Log'.
Please see the 'Connection Log' page in the dashboard, and the Connection Log [documentation](https://coder.com/docs/admin/monitoring/connection-logs) for details. Those with permission to view the Audit Log will also be able to view the Connection Log. The new Connection Log has the same licensing restrictions as the Audit Log, and requires a Premium Coder deployment.
### Context
This is the first PR of a few for moving connection events out of the audit log, and into a new database table and web UI page called the 'Connection Log'.
This PR:
- Creates the new table
- Adds and tests queries for inserting and reading, including reading with an RBAC filter.
- Implements the corresponding RBAC changes, such that anyone who can view the audit log can read from the table
- Implements, under the enterprise package, a `ConnectionLogger` abstraction to replace the `Auditor` abstraction for these logs. (No-op'd in AGPL, like the `Auditor`)
- Routes SSH connection and Workspace App events into the new `ConnectionLogger`
- Updates all existing tests to check the values of the `ConnectionLogger` instead of the `Auditor`.
Future PRs:
- Add filtering to the query
- Add an enterprise endpoint to query the new table
- Write a query to delete old events from the audit log, call it from dbpurge.
- Implement a table in the Web UI for viewing connection logs.
> [!NOTE]
> The PRs in this stack obviously won't be (completely) atomic. Whilst they'll each pass CI, the stack is designed to be merged all at once. I'm splitting them up for the sake of those reviewing, and so changes can be reviewed as early as possible. Despite this, it's really hard to make this PR any smaller than it already is. I'll be keeping it in draft until it's actually ready to merge.
The file cache was caching the `Unauthorized` errors if a user without
the right perms opened the file first. So all future opens would fail.
Now the cache always opens with a subject that can read files. And authz
is checked on the Acquire per user.
Closes https://github.com/coder/internal/issues/619
Implement the `coderd` side of the AgentAPI for the upcoming
dev-container agents work.
`agent/agenttest/client.go` is left unimplemented for a future PR
working to implement the agent side of this feature.
Opting into rego v1. Rego v1 requires `if` for all rule statements.
This PR updates the dependencies and the rego policy itself.
Golang imports upgraded for opa/rego
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore: authz 'any_org' to return if at least 1 org has perms
Allows checking if a user can do an action in any organization,
rather than a specific one. Allows asking general questions on the
UI to determine which elements to show.
* more strict, add comments to policy
* add unit tests and extend to /authcheck api
* make field optional
* chore: refactor user subject logic to be in 1 place
* test: implement test to assert deleted custom roles are omitted
* add unit test for deleted role
* chore: create type for unique role names
Using `string` was confusing when something should be combined with
org context, and when not to. Naming this new name, "RoleIdentifier"
Removes our pseudo rbac resources like `WorkspaceApplicationConnect` in favor of additional verbs like `ssh`. This is to make more intuitive permissions for building custom roles.
The source of truth is now `policy.go`
Just moved `rbac.Action` -> `policy.Action`. This is for the stacked PR to not have circular dependencies when doing autogen. Without this, the autogen can produce broken golang code, which prevents the autogen from compiling.
So just avoiding circular dependencies. Doing this in it's own PR to reduce LoC diffs in the primary PR, since this has 0 functional changes.
* chore: merge authorization contexts
Instead of 2 auth contexts from apikey and dbauthz, merge them to
just use dbauthz. It is annoying to have two.
* fixup authorization reference
#7439 added global caching of RBAC results.
Calls are cached based on hash(subject, object, action).
We often use dbauthz.AsSystemRestricted to handle "internal" authz calls, and these are often repeated with similar arguments and are likely to get cached.
So a transient error doing an authz check on a system function will be cached for up to a minute.
I'm just starting off with excluding context.Canceled but there's likely a whole suite of different errors we want to also exclude from the global cache.
* chore: add /v2 to import module path
go mod requires semantic versioning with versions greater than 1.x
This was a mechanical update by running:
```
go install github.com/marwan-at-work/mod/cmd/mod@latest
mod upgrade
```
Migrate generated files to import /v2
* Fix gen
* test: Add benchmark for static rbac roles
* static roles should only be allocated once
* A unit test that modifies the ast value should not mess with the globals
* Cache subject AST values to avoid reallocating slices
* chore: Optimize rego policy evaluation allocations
Manually convert to ast.Value instead of using generic
json.Marshal conversion.
* Add a unit test that prevents regressions of rego input
The optimized input is always compared to the normal json
marshal parser.