## Stack Part of [CODAGT-1341](https://linear.app/codercom/issue/CODAGT-1341/support-organization-level-skills-in-coder-agents). This stack adds organization skills to Coder Agents: admins publish a skill once per organization, share it with groups or members, and chats in that organization load it next to personal and workspace skills. It replaces the 21-PR draft stack #30584 to #30604 with five PRs, bottom to top: 1. **#30609** data model: one migration, queries, RBAC and dbauthz (this PR) 2. #30610 organization skill API routes and the scope-neutral SDK 3. #30611 sharing ACL routes 4. #30612 settings UI: shared skills table, admin page, member section 5. #30613 chats: chatd loading, `/` menu, docs ## What changes This PR adds the data model and authorization for organization skills. Personal skills keep working unchanged: `git diff origin/main -- codersdk site` shows only generated RBAC and API key scope additions (`apikey_scopes_gen.go`, `rbacresources_gen.go`, `rbacresourcesGenerated.ts`, the RBAC and scope unions in `typesGenerated.ts`) and `codersdk.ResourceTypeOrganizationSkill` with its friendly string. No personal route, SDK type or site component changes, and `coderd/userskills_test.go` changes only by mechanical constant renames. **One migration, `000621_skills`** (please review it closely): - renames `user_skills` to `skills` with its constraints, index and triggers, and recreates the cap function and `delete_deleted_user_resources`, whose PL/pgSQL bodies name the table; - adds `organization_id` and `project_id` (`ON DELETE CASCADE`), `enabled` (default true), and `group_acl` / `user_acl` (`jsonb`, default `{}`), with CHECKs for exactly one owner, ACLs that are JSON objects, and ACLs that stay empty unless an organization owns the row, plus partial unique name indexes per owner kind; - replaces the cap trigger with `enforce_skills_per_owner_limit()`: 100 rows per owner, disabled rows included, locking the owner row `FOR NO KEY UPDATE` so unrelated foreign key checks are not blocked; - adds a `BEFORE DELETE` trigger on `organization_members` that removes a departing member from `user_acl` on that organization's skills, so rejoining doesn't restore a direct grant; - adds the `organization_skill` `resource_type` and `api_key_scope` enum values last; nothing in the migration uses them. The down migration undoes the three old migrations in reverse order: it drops organization and project rows and restores `user_skills`, its names, the cap function and `user_id NOT NULL`. Authorization: a new RBAC resource `organization_skill` (create, read, update, delete, share). Owner and org admin get it; site and org auditors get read; members read only through the skill's ACL, which applies only to members of its organization. `Skill.RBACObject()` returns the organization object with both ACLs; project rows stay denied to everyone. New sqlc queries cover organization insert, get, list (SQL-filtered through `GetAuthorizedOrganizationSkillMetadata` and a `regosql` converter), update and delete, with dbauthz wrappers and the audit table entries. No routes yet. ## Commits The first commit reproduces the reviewed code from the old stack; each later commit is a labeled trim or fix. - `baa3150e3b7` feat: add the organization skills data model. Reproduction: every non-site file this commit changes equals the reviewed old `authz` head `1e16b3004c4`, except that the old stack's migrations 000615, 000616 and 000617 are merged into one migration (then `000615_skills`, now `000621_skills`) with one fixture (`testdata/fixtures/000621_skills.up.sql`) and one migration test (now `testMigration000621Skills`). At that commit, `dump.sql` was byte-identical to the old head's. - `1b84e83463a` test(coderd/database): keep only the SQL paths of the org skill matrix. `TestOrganizationSkillAuthorization` becomes `TestOrganizationSkillQueries` instead of being deleted, because coverage showed it is this PR's only run of the `GetAuthorizedOrganizationSkillMetadata` row scan (80% to 65% without it) and of the organization update and delete queries (100% to 0%). Swapping `group_acl` and `user_acl` in the converter makes `GrantedMember` fail. The removed cases are decided by `TestRolePermissions`, `TestSkillRBACObjectOrganizationACL` and the dbauthz method suite. - `e2931b412e9` test(coderd/database): fold the skill cap cases into existing coverage. The user case is covered by `TestUserSkillLimit` and `TestUserSkillLimitConcurrentCreates`; the organization case folds into `ConcurrentOrganizationInsertsHonorCap`, which now also checks that disabled rows count and another organization is unaffected (mutation checked); the project case stays. - `be62aea9021` test(coderd/rbac): drop `TestOrganizationSkillReadScope`, covered by `TestExternalScopeNames` and the generated scope file. - `0c275ba37de` merges main at `454a2fcc697`, which took 000615 to 000618: it renumbers the migration to 000619 and resolves `dump.sql`, `check_constraint.go` and the generated API docs. `8f2b4b87ee2` merges main at `ad85646164e`, which took 000619: it renumbers the migration to 000620. - `5248d56b64f` merges main at `e7b76872098`, which took 000620 (`000620_audit_chat_project_instructions_resource_type`): it renumbers the migration, fixture and migration test to `000621_skills` (step `Migration000621Skills`, version 621), and resolves `enterprise/audit/table.go` (keeping main's `AuditableChatProjectInstructions` entries and the `Skill` entry), `dump.sql` and `models.go` (the `resource_type` enum gets `chat_project_instructions`, then `organization_skill`) and the regenerated `schemas.md`. The up and down SQL are unchanged by every renumbering. ## Size Production +549/-67, tests +728/-33, generated +881/-314; total +2158/-414. This is above the 500-line reviewer-friendly limit on purpose: Mike asked for 4 to 5 PRs instead of the 21 small ones this stack replaces. ## Replaces #30584 (rename `user_skills` to `skills`), #30585 (skill owners), #30586 (organization skill authorization). ## Validation - Migration test: up keeps the existing personal row and its ID; down restores the `user_skills` names, cap function and `NOT NULL`, and drops organization rows; soft-deleting a user removes their skills after up, after down, and after re-up. - `go test` passed on `be62aea9021`, before the main merges, for `coderd` (skill tests), `coderd/database`, `dbauthz`, `migrations`, `gentest`, `coderd/rbac/...`, `coderd/x/chatd`, `coderd/x/skills`, `enterprise/audit` and `enterprise/coderd`; `make lint/check-scopes` passed; the four personal skills Vitest files pass unchanged. - On `5248d56b64f`: build, vet, `TestMigrate`, `TestMigrationChain`, the `migrations`, `database`, `dbauthz`, `gentest`, `rbac` and `enterprise/audit` packages pass; `make gen` run twice is clean, and `make pre-commit` passes. - Remote UAT round 7 through Coder Agents passed on the whole stack; see #30613's Validation for scope and findings. > Xum opened this PR on behalf of @ibetitsmike.
Authz
Package rbac implements Role-Based Access Control for Coder.
See USAGE.md for a hands-on approach to using this package.
Overview
Authorization defines what permission a subject has to perform actions to objects:
- Permission is binary: yes (allowed) or no (denied).
- Subject in this case is anything that implements interface
rbac.Subject. - Action here is an enumerated list of actions. Actions can differ for each object type. They typically read like,
Create,Read,Update,Delete, etc. - Object here is anything that implements
rbac.Object.
Permission Structure
A permission is a rule that grants or denies access for a subject to perform an action on a object. A permission is always applied at a given level:
- site level applies to all objects in a given Coder deployment.
- org level applies to all objects that have an organization owner (
org_owner) - user level applies to all objects that have an owner with the same ID as the subject.
Permissions at a higher level always override permissions at a lower level.
The effect of a permission can be:
- positive (allows)
- negative (denies)
- abstain (neither allows or denies, not applicable)
Negative permissions always override positive permissions at the same level. Both negative and positive permissions override abstain at the same level.
This can be represented by the following truth table, where Y represents positive, N represents negative, and _ represents abstain:
| Action | Positive | Negative | Result |
|---|---|---|---|
| read | Y | _ | Y |
| read | Y | N | N |
| read | _ | _ | _ |
| read | _ | N | N |
Permission Representation
Permissions are represented in string format as <sign>?<level>.<object>.<id>.<action>, where:
negatedcan be either+or-. If it is omitted, sign is assumed to be+.levelis eithersite,org, oruser.objectis any valid resource type.idis any valid UUID v4.idis included in the permission syntax, however only scopes may useidto specify a specific object.actionis typicallycreate,read,modify,delete, but you can define other verbs as needed.
Example Permissions
+site.app.*.read: allowed to perform thereadaction against all objects of typeappin a given Coder deployment.-user.workspace.*.create: user is not allowed to create workspaces.
Levels
A user can be given (or deprived) a permission at several levels. Currently, those levels are:
- Site-wide level
- Organization level
- User level
- Organization member level
The site-wide level is the most authoritative. Any permission granted or denied at the side-wide level is absolute. After checking the site-wide level, depending of if the resource is owned by an organization or not, it will check the other levels.
-
If the resource is owned by an organization, the next most authoritative level is the organization level. It acts like the site-wide level, but only for resources within the corresponding organization. The user can use that permission on any resource within that organization.
- After the organization level is the member level. This level only applies to resources that are owned by both the organization and the user.
-
If the resource is not owned by an organization, the next level to check is the user level. This level only applies to resources owned by the user and that are not owned by any organization.
┌──────────┐
│ Site │
└─────┬────┘
┌──────────┴───────────┐
┌──┤ Owned by an org? ├──┐
│ └──────────────────────┘ │
┌──┴──┐ ┌──┴─┐
│ Yes │ │ No │
└──┬──┘ └──┬─┘
┌────────┴─────────┐ ┌─────┴────┐
│ Organization │ │ User │
└────────┬─────────┘ └──────────┘
┌─────┴──────┐
│ Member │
└────────────┘
Roles
A role is a set of permissions. When evaluating a role's permission to form an action, all the relevant permissions for the role are combined at each level. Permissions at a higher level override permissions at a lower level.
The following tables show the per-level role evaluation. Y indicates that the role provides positive permissions, N indicates the role provides negative permissions, and indicates the role does not provide positive or negative permissions. YN indicates that the value in the cell does not matter for the access result. The table varies depending on if the resource belongs to an organization or not.
If the resource is owned by an organization, such as a template or a workspace:
| Role (example) | Site | Org | OrgMember | Result |
|---|---|---|---|---|
| site-admin | Y | YN_ | YN_ | Y |
| negative-site-permission | N | YN_ | YN_ | N |
| org-admin | _ | Y | YN_ | Y |
| non-org-member | _ | N | YN_ | N |
| member-owned | _ | _ | Y | Y |
| not-member-owned | _ | _ | N | N |
| unauthenticated | _ | _ | _ | N |
If the resource is not owned by an organization:
| Role (example) | Site | User | Result |
|---|---|---|---|
| site-admin | Y | YN_ | Y |
| negative-site-permission | N | YN_ | N |
| user-owned | _ | Y | Y |
| not-user-owned | _ | N | N |
| unauthenticated | _ | _ | N |
Scopes
Scopes can restrict a given set of permissions. The format of a scope matches a role with the addition of a list of resource ids. For a authorization call to be successful, the subject's roles and the subject's scopes must both allow the action. This means the resulting permissions is the intersection of the subject's roles and the subject's scopes.
An example to give a readonly token is to grant a readonly scope across all resources +site.*.*.read. The intersection with the user's permissions will be the readonly set of their permissions.
Resource IDs
There exists use cases that require specifying a specific resource. If resource IDs are allowed in the roles, then there is an unbounded set of resource IDs that be added to an "allow_list", as the number of roles a user can have is unbounded. This also adds a level of complexity to the role evaluation logic that has large costs at scale.
The use case for specifying this type of permission in a role is limited, and does not justify the extra cost. To solve this for the remaining cases (eg. workspace agent tokens), we can apply an allow_list on a scope. For most cases, the allow_list will just be ["*"] which means the scope is allowed to be applied to any resource. This adds negligible cost to the role evaluation logic and 0 cost to partial evaluations.
Example of a scope for a workspace agent token, using an allow_list containing a single resource id.
{
"scope": {
"name": "workspace_agent",
"display_name": "Workspace_Agent",
// The ID of the given workspace the agent token correlates to.
"allow_list": ["10d03e62-7703-4df5-a358-4f76577d4e2f"],
"site": [/* ... perms ... */],
"org": {/* ... perms ... */},
"user": [/* ... perms ... */]
}
}
OPA (Open Policy Agent)
Open Policy Agent (OPA) is an open source tool used to define and enforce policies.
Policies are written in a high-level, declarative language called Rego.
Coder’s RBAC rules are defined in the policy.rego file under the authz package.
When OPA evaluates policies, it binds input data to a global variable called input.
In the rbac package, this structured data is defined as JSON and contains the action, object and subject (see regoInputValue in astvalue.go).
OPA evaluates whether the subject is allowed to perform the action on the object across three levels: site, org, and user.
This is determined by the final rule allow, which aggregates the results of multiple rules to decide if the user has the necessary permissions.
Similarly to the input, OPA produces structured output data, which includes the allow variable as part of the evaluation result.
Authorization succeeds only if allow explicitly evaluates to true. If no allow is returned, it is considered unauthorized.
To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs.
Application and Database Integration
rbac/authz.go– Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation.database/dbauthz/dbauthz.go– Database layer integration: wraps the database layer with authorization checks to enforce access control.
There are two types of evaluation in OPA:
- Full evaluation: Produces a decision that can be enforced.
This is the default evaluation mode, where OPA evaluates the policy using
inputdata that contains all known values and returns output data with theallowvariable. - Partial evaluation: Produces a new policy that can be evaluated later when the unknowns become known.
This is an optimization in OPA where it evaluates as much of the policy as possible without resolving expressions that depend on unknown values from the
input. To learn more about partial evaluation, see this OPA blog post.
Application of Full and Partial evaluation in rbac package:
- Full Evaluation is handled by the
RegoAuthorizer.Authorize()method inauthz.go. This method determines whether a subject (user) can perform a specific action on an object. It performs a full evaluation of the Rego policy, which returns theallowvariable to decide whether access is granted (true) or denied (falseor undefined). - Partial Evaluation is handled by the
RegoAuthorizer.Prepare()method inauthz.go. This method compiles OPA’s partial evaluation queries intoSQL WHEREclauses. These clauses are then used to enforce authorization directly in database queries, rather than in application code.
Authorization Patterns:
- Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via
Authorize(). - Authorize-while-fetching: Partial evaluation via
Prepare()is used to inject SQL filters directly into queries, allowing efficient authorization of many objects of the same type.dbauthzmethods that enforce authorization directly in the SQL query are prefixed withAuthorized, for example,GetAuthorizedWorkspaces.
Testing
- OPA Playground: https://play.openpolicyagent.org/
- OPA CLI (
opa eval): useful for experimenting with different inputs and understanding how the policy behaves under various conditions.opa evalreturns the constraints that must be satisfied for a rule to evaluate totrue.opa evalrequires aninput.jsonfile containing the input data to run the policy against. You can generate this file using the gen_input.go script. Note: the script currently produces a fixed input. You may need to tweak it for your specific use case.
Full Evaluation
opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json
This command fully evaluates the policy in the policy.rego file using the input data from input.json, and returns the result of the allow variable:
data.authz.allowaccesses theallowrule within theauthzpackage.data.authzon its own would return the entire output object of the package.
This command answers the question: “Is the user allowed?”
Partial Evaluation
opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.id --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json
This command performs a partial evaluation of the policy, specifying a set of unknown input parameters.
The result is a set of partial queries that can be converted into SQL WHERE clauses and injected into SQL queries.
This command answers the question: “What conditions must be met for the user to be allowed?”
Benchmarking
Benchmark tests to evaluate the performance of full and partial evaluation can be found in authz_test.go.
You can run these tests with the -bench flag, for example:
go test -bench=BenchmarkRBACFilter -run=^$
To capture memory and CPU profiles, use the following flags:
-memprofile memprofile.out-cpuprofile cpuprofile.out
The script benchmark_authz.sh runs the authz benchmark tests on the current Git branch or compares benchmark results between two branches using benchstat.
benchstat compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences.
-
To run benchmark on the current branch:
benchmark_authz.sh --single -
To compare benchmarks between 2 branches:
benchmark_authz.sh --compare main prebuild_policy