mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## 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.
332 lines
13 KiB
YAML
332 lines
13 KiB
YAML
# sqlc is used to generate types from sql schema language.
|
|
# It was chosen to ensure type-safety when interacting with
|
|
# the database.
|
|
version: "2"
|
|
cloud:
|
|
# This is the static ID for the coder project.
|
|
project: "01HEP08N3WKWRFZT3ZZ9Q37J8X"
|
|
sql:
|
|
- schema: "./dump.sql"
|
|
queries: "./queries"
|
|
engine: "postgresql"
|
|
# This only works if you are running a local postgres database with the
|
|
# schema loaded and migrations run. Run `make sqlc-vet` to run the linter.
|
|
database:
|
|
uri: "${SQLC_DATABASE_URL}"
|
|
analyzer:
|
|
database: false
|
|
rules:
|
|
- sqlc/db-prepare
|
|
- do-not-use-public-schema-in-queries
|
|
gen:
|
|
go:
|
|
package: "database"
|
|
out: "./queries"
|
|
emit_interface: true
|
|
emit_json_tags: true
|
|
emit_db_tags: true
|
|
emit_enum_valid_method: true
|
|
emit_all_enum_values: true
|
|
initialisms:
|
|
# Keep the sqlc default initialism and add AI so generated
|
|
# identifiers follow Go naming conventions.
|
|
- id
|
|
- ai
|
|
- nats
|
|
overrides:
|
|
- column: "api_keys.scopes"
|
|
go_type:
|
|
type: "APIKeyScopes"
|
|
- column: "api_keys.allow_list"
|
|
go_type:
|
|
type: "AllowList"
|
|
- db_type: "agent_id_name_pair"
|
|
go_type:
|
|
type: "AgentIDNamePair"
|
|
# Used in 'CustomRoles' query to filter by (name,organization_id)
|
|
- db_type: "name_organization_pair"
|
|
go_type:
|
|
type: "NameOrganizationPair"
|
|
- db_type: "tagset"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "custom_roles.site_permissions"
|
|
go_type:
|
|
type: "CustomRolePermissions"
|
|
- column: "custom_roles.org_permissions"
|
|
go_type:
|
|
type: "CustomRolePermissions"
|
|
- column: "custom_roles.user_permissions"
|
|
go_type:
|
|
type: "CustomRolePermissions"
|
|
- column: "custom_roles.member_permissions"
|
|
go_type:
|
|
type: "CustomRolePermissions"
|
|
- column: "provisioner_daemons.tags"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "provisioner_keys.tags"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "provisioner_jobs.tags"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "chats.labels"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "chats_expanded.labels"
|
|
go_type:
|
|
type: "StringMap"
|
|
- column: "chats.user_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "chats.group_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "chat_model_configs.user_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "chat_model_configs.group_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "mcp_server_configs.user_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "mcp_server_configs.group_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "skills.user_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "skills.group_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "chats_expanded.user_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "chats_expanded.group_acl"
|
|
go_type:
|
|
type: "ChatACL"
|
|
- column: "users.rbac_roles"
|
|
go_type: "github.com/lib/pq.StringArray"
|
|
- column: "templates.user_acl"
|
|
go_type:
|
|
type: "TemplateACL"
|
|
- column: "templates.group_acl"
|
|
go_type:
|
|
type: "TemplateACL"
|
|
- column: "template_with_names.user_acl"
|
|
go_type:
|
|
type: "TemplateACL"
|
|
- column: "template_with_names.group_acl"
|
|
go_type:
|
|
type: "TemplateACL"
|
|
- column: "template_usage_stats.app_usage_mins"
|
|
go_type:
|
|
type: "StringMapOfInt"
|
|
- column: "app_family_usage.session_family_usage_seconds"
|
|
go_type:
|
|
type: "StringMapOfInt"
|
|
- column: "tasks_with_status.workspace_user_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "tasks_with_status.workspace_group_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "workspaces.user_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "workspaces.group_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "workspaces_expanded.user_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "workspaces_expanded.group_acl"
|
|
go_type:
|
|
type: "WorkspaceACL"
|
|
- column: "workspaces_expanded.user_acl_display_info"
|
|
go_type:
|
|
type: "WorkspaceACLDisplayInfo"
|
|
- column: "workspaces_expanded.group_acl_display_info"
|
|
go_type:
|
|
type: "WorkspaceACLDisplayInfo"
|
|
- column: "notification_templates.actions"
|
|
go_type:
|
|
type: "[]byte"
|
|
- column: "notification_messages.payload"
|
|
go_type:
|
|
type: "[]byte"
|
|
- column: "provisioner_job_stats.*_secs"
|
|
go_type:
|
|
type: "float64"
|
|
- column: "user_links.claims"
|
|
go_type:
|
|
type: "UserLinkClaims"
|
|
# Workaround for sqlc not interpreting the left join correctly.
|
|
- column: "tasks_with_status.workspace_build_number"
|
|
go_type: "database/sql.NullInt32"
|
|
- column: "tasks_with_status.status"
|
|
go_type:
|
|
type: "TaskStatus"
|
|
- column: "tasks_with_status.workspace_agent_lifecycle_state"
|
|
go_type:
|
|
type: "NullWorkspaceAgentLifecycleState"
|
|
- column: "tasks_with_status.workspace_app_health"
|
|
go_type:
|
|
type: "NullWorkspaceAppHealth"
|
|
# Workaround for sqlc not interpreting the left join correctly
|
|
# in the combined telemetry query.
|
|
- column: "task_event_data.start_build_number"
|
|
go_type: "database/sql.NullInt32"
|
|
- column: "task_event_data.stop_build_created_at"
|
|
go_type: "database/sql.NullTime"
|
|
- column: "task_event_data.stop_build_reason"
|
|
go_type:
|
|
type: "NullBuildReason"
|
|
- column: "task_event_data.start_build_created_at"
|
|
go_type: "database/sql.NullTime"
|
|
- column: "task_event_data.start_build_reason"
|
|
go_type:
|
|
type: "NullBuildReason"
|
|
- column: "task_event_data.last_working_status_at"
|
|
go_type: "database/sql.NullTime"
|
|
- column: "task_event_data.first_status_after_resume_at"
|
|
go_type: "database/sql.NullTime"
|
|
- db_type: "pg_catalog.numeric"
|
|
go_type:
|
|
import: "github.com/shopspring/decimal"
|
|
type: "Decimal"
|
|
package: "decimal"
|
|
- db_type: "pg_catalog.numeric"
|
|
nullable: true
|
|
go_type:
|
|
import: "github.com/shopspring/decimal"
|
|
type: "NullDecimal"
|
|
package: "decimal"
|
|
rename:
|
|
connection_log_method_ssh: ConnectionLogMethodSSH
|
|
connection_log_method_reconnecting_pty: ConnectionLogMethodReconnectingPTY
|
|
ai_provider_id: AIProviderID
|
|
chat: ChatTable
|
|
chats_expanded: Chat
|
|
group_member: GroupMemberTable
|
|
group_members_expanded: GroupMember
|
|
template: TemplateTable
|
|
template_with_name: Template
|
|
workspace_build: WorkspaceBuildTable
|
|
workspace_build_with_user: WorkspaceBuild
|
|
workspace: WorkspaceTable
|
|
workspaces_expanded: Workspace
|
|
task: TaskTable
|
|
tasks_with_status: Task
|
|
template_version: TemplateVersionTable
|
|
template_version_with_user: TemplateVersion
|
|
api_key: APIKey
|
|
api_key_scope: APIKeyScope
|
|
api_key_scope_all: APIKeyScopeAll
|
|
api_key_scope_application_connect: APIKeyScopeApplicationConnect
|
|
api_version: APIVersion
|
|
avatar_url: AvatarURL
|
|
created_by_avatar_url: CreatedByAvatarURL
|
|
diff_url: DiffURL
|
|
dbcrypt_key: DBCryptKey
|
|
session_count_vscode: SessionCountVSCode
|
|
session_count_jetbrains: SessionCountJetBrains
|
|
session_count_reconnecting_pty: SessionCountReconnectingPTY
|
|
session_count_ssh: SessionCountSSH
|
|
connection_median_latency_ms: ConnectionMedianLatencyMS
|
|
login_type_oidc: LoginTypeOIDC
|
|
oauth_access_token: OAuthAccessToken
|
|
oauth_access_token_key_id: OAuthAccessTokenKeyID
|
|
oauth_expiry: OAuthExpiry
|
|
oauth_id_token: OAuthIDToken
|
|
oauth_refresh_token: OAuthRefreshToken
|
|
oauth_refresh_token_key_id: OAuthRefreshTokenKeyID
|
|
oauth_extra: OAuthExtra
|
|
parameter_type_system_hcl: ParameterTypeSystemHCL
|
|
userstatus: UserStatus
|
|
gitsshkey: GitSSHKey
|
|
rbac_roles: RBACRoles
|
|
ip_address: IPAddress
|
|
ip_addresses: IPAddresses
|
|
ids: IDs
|
|
jwt: JWT
|
|
user_acl: UserACL
|
|
group_acl: GroupACL
|
|
workspace_user_acl: WorkspaceUserACL
|
|
workspace_group_acl: WorkspaceGroupACL
|
|
user_acl_display_info: UserACLDisplayInfo
|
|
group_acl_display_info: GroupACLDisplayInfo
|
|
troubleshooting_url: TroubleshootingURL
|
|
default_ttl: DefaultTTL
|
|
motd_file: MOTDFile
|
|
uuid: UUID
|
|
failure_ttl: FailureTTL
|
|
time_til_dormant_autodelete: TimeTilDormantAutoDelete
|
|
eof: EOF
|
|
template_ids: TemplateIDs
|
|
active_user_ids: ActiveUserIDs
|
|
display_app_ssh_helper: DisplayAppSSHHelper
|
|
oauth2_provider_app: OAuth2ProviderApp
|
|
oauth2_provider_app_secret: OAuth2ProviderAppSecret
|
|
oauth2_provider_app_code: OAuth2ProviderAppCode
|
|
oauth2_provider_app_token: OAuth2ProviderAppToken
|
|
api_key_id: APIKeyID
|
|
callback_url: CallbackURL
|
|
login_type_oauth2_provider_app: LoginTypeOAuth2ProviderApp
|
|
crypto_key_feature_workspace_apps_api_key: CryptoKeyFeatureWorkspaceAppsAPIKey
|
|
crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert
|
|
crypto_key_feature_nats_ca: CryptoKeyFeatureNATSCA
|
|
stale_interval_ms: StaleIntervalMS
|
|
has_ai_task: HasAITask
|
|
ai_task_sidebar_app_id: AITaskSidebarAppID
|
|
latest_build_has_ai_task: LatestBuildHasAITask
|
|
cors_behavior: CorsBehavior
|
|
aibridge_interception: AIBridgeInterception
|
|
aibridge_interception_error_type: AIBridgeInterceptionErrorType
|
|
aibridge_tool_usage: AIBridgeToolUsage
|
|
aibridge_token_usage: AIBridgeTokenUsage
|
|
aibridge_user_prompt: AIBridgeUserPrompt
|
|
aibridge_model_thought: AIBridgeModelThought
|
|
ai_provider: AIProvider
|
|
ai_provider_key: AIProviderKey
|
|
ai_provider_type: AIProviderType
|
|
ai_gateway_key: AIGatewayKey
|
|
resource_type_ai_provider: ResourceTypeAIProvider
|
|
resource_type_ai_provider_key: ResourceTypeAIProviderKey
|
|
resource_type_ai_gateway_key: ResourceTypeAIGatewayKey
|
|
mcp_server_config: MCPServerConfig
|
|
mcp_server_configs: MCPServerConfigs
|
|
chat_mcp_server: ChatMCPServer
|
|
chat_mcp_servers: ChatMCPServers
|
|
resource_type_mcp_server_config: ResourceTypeMCPServerConfig
|
|
mcp_server_user_token: MCPServerUserToken
|
|
mcp_server_user_tokens: MCPServerUserTokens
|
|
mcp_server_tool_snapshot: MCPServerToolSnapshot
|
|
mcp_server_tool_snapshots: MCPServerToolSnapshots
|
|
mcp_server_config_id: MCPServerConfigID
|
|
mcp_server_ids: MCPServerIDs
|
|
max_file_links: MaxFileLinks
|
|
icon_url: IconURL
|
|
oauth2_client_id: OAuth2ClientID
|
|
oauth2_client_secret: OAuth2ClientSecret
|
|
oauth2_client_secret_key_id: OAuth2ClientSecretKeyID
|
|
oauth2_auth_url: OAuth2AuthURL
|
|
oauth2_token_url: OAuth2TokenURL
|
|
oauth2_revocation_url: OAuth2RevocationURL
|
|
oauth2_scopes: OAuth2Scopes
|
|
api_key_header: APIKeyHeader
|
|
api_key_value: APIKeyValue
|
|
api_key_value_key_id: APIKeyValueKeyID
|
|
custom_headers_key_id: CustomHeadersKeyID
|
|
tools_json: ToolsJSON
|
|
access_token_key_id: AccessTokenKeyID
|
|
refresh_token_key_id: RefreshTokenKeyID
|
|
rules:
|
|
- name: do-not-use-public-schema-in-queries
|
|
message: "do not use public schema in queries"
|
|
# FIXME: It would be great to run sqlc-vet against `migrations` directory and `dump.sql`.
|
|
rule: >
|
|
query.sql.matches(r'[^a-z]public\.')
|