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.
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ResourceType string
|
|
|
|
const (
|
|
ResourceTypeTemplate ResourceType = "template"
|
|
ResourceTypeTemplateVersion ResourceType = "template_version"
|
|
ResourceTypeUser ResourceType = "user"
|
|
ResourceTypeWorkspace ResourceType = "workspace"
|
|
ResourceTypeWorkspaceBuild ResourceType = "workspace_build"
|
|
ResourceTypeGitSSHKey ResourceType = "git_ssh_key"
|
|
ResourceTypeAPIKey ResourceType = "api_key"
|
|
ResourceTypeGroup ResourceType = "group"
|
|
ResourceTypeLicense ResourceType = "license"
|
|
ResourceTypeConvertLogin ResourceType = "convert_login"
|
|
ResourceTypeHealthSettings ResourceType = "health_settings"
|
|
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
|
|
ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
|
|
ResourceTypeOAuth2ProviderSettings ResourceType = "oauth2_provider_settings"
|
|
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
|
|
ResourceTypeOrganization ResourceType = "organization"
|
|
ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
|
|
// nolint:gosec // This is not a secret.
|
|
ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
|
|
ResourceTypeCustomRole ResourceType = "custom_role"
|
|
ResourceTypeOrganizationMember ResourceType = "organization_member"
|
|
ResourceTypeNotificationTemplate ResourceType = "notification_template"
|
|
ResourceTypeIdpSyncSettingsOrganization ResourceType = "idp_sync_settings_organization"
|
|
ResourceTypeIdpSyncSettingsGroup ResourceType = "idp_sync_settings_group"
|
|
ResourceTypeIdpSyncSettingsRole ResourceType = "idp_sync_settings_role"
|
|
// Deprecated: Workspace Agent connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
ResourceTypeWorkspaceApp ResourceType = "workspace_app"
|
|
// ResourceTypeTask is retained for audit logs recorded before the Tasks
|
|
// feature was removed.
|
|
ResourceTypeTask ResourceType = "task"
|
|
ResourceTypeAISeat ResourceType = "ai_seat"
|
|
ResourceTypeAIProvider ResourceType = "ai_provider"
|
|
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
|
|
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
|
|
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
|
|
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
|
|
ResourceTypeChat ResourceType = "chat"
|
|
ResourceTypeChatProject ResourceType = "chat_project"
|
|
ResourceTypeChatProjectMemory ResourceType = "chat_project_memory"
|
|
ResourceTypeMCPServerConfig ResourceType = "mcp_server_config"
|
|
ResourceTypeChatModelConfig ResourceType = "chat_model_config"
|
|
// ResourceTypeChatAutomation is retained for audit logs recorded before
|
|
// the chat automations experiment was removed.
|
|
ResourceTypeChatAutomation ResourceType = "chat_automation"
|
|
ResourceTypeUserSecret ResourceType = "user_secret"
|
|
ResourceTypeUserSkill ResourceType = "user_skill"
|
|
ResourceTypeOrganizationSkill ResourceType = "organization_skill"
|
|
ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
|
|
ResourceTypeChatOperationalSettings ResourceType = "chat_operational_settings"
|
|
ResourceTypeChatOrganizationSystemPrompt ResourceType = "chat_organization_system_prompt"
|
|
ResourceTypeChatProjectInstructions ResourceType = "chat_project_instructions"
|
|
ResourceTypeExperimentRule ResourceType = "experiment_rule"
|
|
)
|
|
|
|
func (r ResourceType) FriendlyString() string {
|
|
switch r {
|
|
case ResourceTypeTemplate:
|
|
return "template"
|
|
case ResourceTypeTemplateVersion:
|
|
return "template version"
|
|
case ResourceTypeUser:
|
|
return "user"
|
|
case ResourceTypeWorkspace:
|
|
return "workspace"
|
|
case ResourceTypeWorkspaceBuild:
|
|
// workspace builds have a unique friendly string
|
|
// see coderd/audit.go:298 for explanation
|
|
return "workspace"
|
|
case ResourceTypeGitSSHKey:
|
|
return "git ssh key"
|
|
case ResourceTypeAPIKey:
|
|
return "token"
|
|
case ResourceTypeGroup:
|
|
return "group"
|
|
case ResourceTypeLicense:
|
|
return "license"
|
|
case ResourceTypeConvertLogin:
|
|
return "login type conversion"
|
|
case ResourceTypeWorkspaceProxy:
|
|
return "workspace proxy"
|
|
case ResourceTypeOrganization:
|
|
return "organization"
|
|
case ResourceTypeHealthSettings:
|
|
return "health_settings"
|
|
case ResourceTypeNotificationsSettings:
|
|
return "notifications_settings"
|
|
case ResourceTypePrebuildsSettings:
|
|
return "prebuilds_settings"
|
|
case ResourceTypeOAuth2ProviderSettings:
|
|
return "oauth2 provider settings"
|
|
case ResourceTypeOAuth2ProviderApp:
|
|
return "oauth2 app"
|
|
case ResourceTypeOAuth2ProviderAppSecret:
|
|
return "oauth2 app secret"
|
|
case ResourceTypeCustomRole:
|
|
return "custom role"
|
|
case ResourceTypeOrganizationMember:
|
|
return "organization member"
|
|
case ResourceTypeNotificationTemplate:
|
|
return "notification template"
|
|
case ResourceTypeIdpSyncSettingsOrganization:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsGroup:
|
|
return "settings"
|
|
case ResourceTypeIdpSyncSettingsRole:
|
|
return "settings"
|
|
case ResourceTypeWorkspaceAgent:
|
|
return "workspace agent"
|
|
case ResourceTypeWorkspaceApp:
|
|
return "workspace app"
|
|
case ResourceTypeTask:
|
|
return "task"
|
|
case ResourceTypeAISeat:
|
|
return "ai seat"
|
|
case ResourceTypeAIProvider:
|
|
return "ai provider"
|
|
case ResourceTypeAIProviderKey:
|
|
return "ai provider key"
|
|
case ResourceTypeAIGatewayKey:
|
|
return "ai gateway key"
|
|
case ResourceTypeGroupAIBudget:
|
|
return "group ai budget"
|
|
case ResourceTypeUserAIBudgetOverride:
|
|
return "user ai budget override"
|
|
case ResourceTypeChat:
|
|
return "chat"
|
|
case ResourceTypeChatProject:
|
|
return "chat project"
|
|
case ResourceTypeChatProjectMemory:
|
|
return "chat project memory"
|
|
case ResourceTypeMCPServerConfig:
|
|
return "mcp server config"
|
|
case ResourceTypeChatModelConfig:
|
|
return "chat model config"
|
|
case ResourceTypeChatAutomation:
|
|
return "chat automation"
|
|
case ResourceTypeUserSecret:
|
|
return "user secret"
|
|
case ResourceTypeUserSkill:
|
|
return "user skill"
|
|
case ResourceTypeOrganizationSkill:
|
|
return "organization skill"
|
|
case ResourceTypeChatInstructionSettings:
|
|
return "chat instruction settings"
|
|
case ResourceTypeChatOperationalSettings:
|
|
return "chat operational settings"
|
|
case ResourceTypeChatOrganizationSystemPrompt:
|
|
return "chat organization system prompt"
|
|
case ResourceTypeChatProjectInstructions:
|
|
return "chat project instructions"
|
|
case ResourceTypeExperimentRule:
|
|
return "experiment rule"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditAction string
|
|
|
|
const (
|
|
AuditActionCreate AuditAction = "create"
|
|
AuditActionWrite AuditAction = "write"
|
|
AuditActionDelete AuditAction = "delete"
|
|
AuditActionStart AuditAction = "start"
|
|
AuditActionStop AuditAction = "stop"
|
|
AuditActionLogin AuditAction = "login"
|
|
AuditActionLogout AuditAction = "logout"
|
|
AuditActionRegister AuditAction = "register"
|
|
AuditActionRequestPasswordReset AuditAction = "request_password_reset"
|
|
// Deprecated: Workspace connections are now included in the
|
|
// connection log.
|
|
AuditActionConnect AuditAction = "connect"
|
|
// Deprecated: Workspace disconnections are now included in the
|
|
// connection log.
|
|
AuditActionDisconnect AuditAction = "disconnect"
|
|
// Deprecated: Workspace App connections are now included in the
|
|
// connection log.
|
|
AuditActionOpen AuditAction = "open"
|
|
// Deprecated: This action is unused.
|
|
AuditActionClose AuditAction = "close"
|
|
)
|
|
|
|
func (a AuditAction) Friendly() string {
|
|
switch a {
|
|
case AuditActionCreate:
|
|
return "created"
|
|
case AuditActionWrite:
|
|
return "updated"
|
|
case AuditActionDelete:
|
|
return "deleted"
|
|
case AuditActionStart:
|
|
return "started"
|
|
case AuditActionStop:
|
|
return "stopped"
|
|
case AuditActionLogin:
|
|
return "logged in"
|
|
case AuditActionLogout:
|
|
return "logged out"
|
|
case AuditActionRegister:
|
|
return "registered"
|
|
case AuditActionRequestPasswordReset:
|
|
return "password reset requested"
|
|
case AuditActionConnect:
|
|
return "connected"
|
|
case AuditActionDisconnect:
|
|
return "disconnected"
|
|
case AuditActionOpen:
|
|
return "opened"
|
|
case AuditActionClose:
|
|
return "closed"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
type AuditDiff map[string]AuditDiffField
|
|
|
|
type AuditDiffField struct {
|
|
Old any `json:"old,omitempty"`
|
|
New any `json:"new,omitempty"`
|
|
Secret bool `json:"secret"`
|
|
}
|
|
|
|
type AuditLog struct {
|
|
ID uuid.UUID `json:"id" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id" format:"uuid"`
|
|
Time time.Time `json:"time" format:"date-time"`
|
|
IP netip.Addr `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID uuid.UUID `json:"resource_id" format:"uuid"`
|
|
// ResourceTarget is the name of the resource.
|
|
ResourceTarget string `json:"resource_target"`
|
|
ResourceIcon string `json:"resource_icon"`
|
|
Action AuditAction `json:"action"`
|
|
Diff AuditDiff `json:"diff"`
|
|
StatusCode int32 `json:"status_code"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields" swaggertype:"object"`
|
|
Description string `json:"description"`
|
|
ResourceLink string `json:"resource_link"`
|
|
IsDeleted bool `json:"is_deleted"`
|
|
|
|
// Deprecated: Use 'organization.id' instead.
|
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
|
|
|
Organization *MinimalOrganization `json:"organization,omitempty"`
|
|
|
|
User *User `json:"user"`
|
|
}
|
|
|
|
type AuditLogsRequest struct {
|
|
SearchQuery string `json:"q,omitempty"`
|
|
Pagination
|
|
}
|
|
|
|
type AuditLogResponse struct {
|
|
AuditLogs []AuditLog `json:"audit_logs"`
|
|
Count int64 `json:"count"`
|
|
CountCap int64 `json:"count_cap"`
|
|
}
|
|
|
|
type CreateTestAuditLogRequest struct {
|
|
Action AuditAction `json:"action,omitempty" enums:"create,write,delete,start,stop"`
|
|
ResourceType ResourceType `json:"resource_type,omitempty" enums:"template,template_version,user,workspace,workspace_build,git_ssh_key,auditable_group"`
|
|
ResourceID uuid.UUID `json:"resource_id,omitempty" format:"uuid"`
|
|
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
|
|
Time time.Time `json:"time,omitempty" format:"date-time"`
|
|
BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"`
|
|
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid"`
|
|
RequestID uuid.UUID `json:"request_id,omitempty" format:"uuid"`
|
|
}
|
|
|
|
// AuditLogs retrieves audit logs from the given page.
|
|
func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, req.asRequestOption(), func(r *http.Request) {
|
|
q := r.URL.Query()
|
|
var params []string
|
|
if req.SearchQuery != "" {
|
|
params = append(params, req.SearchQuery)
|
|
}
|
|
q.Set("q", strings.Join(params, " "))
|
|
r.URL.RawQuery = q.Encode()
|
|
})
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return AuditLogResponse{}, ReadBodyAsError(res)
|
|
}
|
|
|
|
var logRes AuditLogResponse
|
|
err = ReadBodyAsJSON(res, &logRes)
|
|
if err != nil {
|
|
return AuditLogResponse{}, err
|
|
}
|
|
|
|
return logRes, nil
|
|
}
|
|
|
|
// CreateTestAuditLog creates a fake audit log. Only owners of the organization
|
|
// can perform this action. It's used for testing purposes.
|
|
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|