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 2. **#30610** organization skill API routes and the scope-neutral SDK (this PR) 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 organization skill CRUD routes and makes the skill SDK types owner-neutral so personal and organization skills share them. **API contract change (experimental API, personal and organization skills):** skill list and get responses gain `enabled`, and `PATCH .../skills/{skillName}` now takes `{content?, enabled?}`, so `content` is optional and a toggle doesn't resend it. A PATCH with neither field returns 400; `content`, when present, is validated as before. In the SDK, `UserSkill`, `UserSkillMetadata`, `CreateUserSkillRequest` and `UpdateUserSkillRequest` become `Skill`, `SkillMetadata`, `CreateSkillRequest` and `UpdateSkillRequest` (`codersdk/userskills.go` moves to `codersdk/skills.go`), and `UpdateSkillRequest.Content` is now `*string`. The personal client method names are unchanged. The site changes are the handwritten edits that rename forces (`api.ts`, `queries/userSkills.ts`, `testHelpers/skills.ts` and three call sites), with no UI change. Organization routes under `/api/experimental/organizations/{organization}/skills`, not gated by an experiment or license, like the personal ones: - `GET` lists with the caller's own permissions, so admins, the site owner and org auditors see every skill and members see only skills shared with them. `GET /{skillName}` goes through the new `httpmw.ExtractOrganizationSkillParam` and admits only read, so a missing skill and a denied one both return 404. - `POST` checks `create` on the organization before reading the body, then seeds `group_acl` with the Everyone group (whose ID is the organization ID) so a new skill reaches every member until an admin narrows it. The 100-skill cap and a duplicate name return 409. - `PATCH` admits only callers who can read the skill, because its response includes the content; `DELETE` admits callers who can read or delete it. A reader who can't change the skill gets a write-specific 403 from the database wrapper, and everyone else gets 404. PATCH locks the row as system, updates as the caller in the same transaction, and audits the locked row only after the transaction commits. - Audit: `database.AuditableOrganizationSkill` records resource type `organization_skill` with the organization ID; personal skills keep `user_skill`. Personal and organization handlers share one create and update path in `coderd/userskills.go` (`createSkill`, `updateSkill`, and a `skillWriteErrors` table per owner and action). ## Commits The first commit reproduces the reviewed code from the old stack; each later commit is a labeled trim or fix. - `49cb2c84276` feat: add organization skill API routes. Reproduction: every file this commit changes, including the SDK layer's site edits, equals the reviewed old `write-routes` head `aa597100f28`; the rest of the tree differs only by #30609's migration merge and main. - `dc893bc60d6` refactor(coderd): share the skill create and update path across owners (production +164/-188). It keeps the organization create check before the body read, the lock as system then update as caller order, and audit assignment after the transaction. No swagger, `codersdk` or `typesGenerated.ts` change; `make gen` is clean. - `f94925425ef` test(coderd): merge duplicate personal and organization skill tests. `TestSkillsCRUD`, `TestSkillValidationAndConflicts` and `TestSkillAudit` are table-driven over Personal, OrgAdmin and SiteOwner, alongside `TestOrganizationSkillAuditsDeniedWrites` and one `ScopedAPIKeys` subtest; `TestPatchUserSkill` is deleted because none of its blocks were unique. Coverage of `userskills.go`, `organizationskills.go` and `httpmw/organizationskillparam.go` is 101 of 117 blocks before and after, the same block set. - `ad0a7a37504` fix(coderd): require read access to patch an organization skill. The organization PATCH route now admits only `read`, because its response carries the skill's content: a key scoped to `organization_skill:update` without read gets 404, like GET, instead of 200 with the content. Readers without update still get 403, and DELETE is unchanged. `TestOrganizationSkillAccess/ScopedAPIKeys` asserts the 404, that the response has no content, and that the skill is unchanged; against the old route it fails with 200. - `69d00ded0b6` and `f67b79343ec` merge the updated #30609 layer, including main; `69d00ded0b6` resolves a conflict in the personal skills page with main. No other changes of their own. - `18373ffd59e` merges the updated #30609 layer (main at `e7b76872098`) and reapplies the `AuditableOrganizationSkill` entry in `enterprise/audit/table.go` alongside main's new `AuditableChatProjectInstructions` entries. ## Size Production +838/-308, tests +733/-322, generated +801/-274; total +2372/-904. 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 #30587 (scope-neutral SDK and personal enabled state), #30588 (read routes), #30589 (write routes and audit). ## Validation - The coverage comparison above, before and after the test merge. - `make gen` leaves no diff after the shared create and update refactor. - On `18373ffd59e`: `make gen` is clean; build, vet, the `coderd` and `enterprise/coderd` skill tests, `rbac`, `httpmw`, `dbauthz` and `enterprise/audit` pass; `pnpm check`, `pnpm lint` and `make pre-commit` pass. - 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.
407 lines
13 KiB
Go
407 lines
13 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/x/skills"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const (
|
|
// skillJSONEscapeExpansion is the maximum expansion for one byte in a JSON string.
|
|
skillJSONEscapeExpansion = 6
|
|
// skillRequestEnvelopeBytes leaves room for the surrounding JSON object.
|
|
skillRequestEnvelopeBytes = 1024
|
|
// maxSkillRequestBytes allows worst-case JSON string escaping for
|
|
// otherwise valid raw skill content.
|
|
maxSkillRequestBytes = skills.MaxPersonalSkillSizeBytes*skillJSONEscapeExpansion + skillRequestEnvelopeBytes
|
|
|
|
// These names are raised by trigger functions with USING CONSTRAINT.
|
|
// They are not table CHECK constraints, so dbgen does not emit them in
|
|
// check_constraint.go.
|
|
skillsPerUserLimitConstraint database.CheckConstraint = "skills_per_user_limit"
|
|
userSkillUserDeletedConstraint database.CheckConstraint = "user_skill_user_deleted"
|
|
)
|
|
|
|
// @Summary Create a user skill
|
|
// @ID create-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param request body codersdk.CreateSkillRequest true "Create user skill request"
|
|
// @Success 201 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) postUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
skill, ok := createSkill(ctx, rw, r, userSkillCreateErrors, func(parsed skills.ParsedSkill, content string) (database.Skill, error) {
|
|
return api.Database.InsertUserSkill(ctx, database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: user.ID,
|
|
Name: parsed.Name,
|
|
Description: parsed.Description,
|
|
Content: content,
|
|
})
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
aReq.New = skill
|
|
}
|
|
|
|
// @Summary List user skills
|
|
// @ID list-user-skills
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Success 200 {array} codersdk.SkillMetadata
|
|
// @Router /api/experimental/users/{user}/skills [get]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) getUserSkills(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
|
|
rows, err := api.Database.ListUserSkillMetadataByUserID(ctx, user.ID)
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSkillMetadataList(rows))
|
|
}
|
|
|
|
// @Summary Get a user skill by name
|
|
// @ID get-a-user-skill-by-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Success 200 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [get]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) getUserSkill(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
name := chi.URLParam(r, "skillName")
|
|
|
|
skill, err := api.Database.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Skill(skill))
|
|
}
|
|
|
|
// @Summary Update a user skill
|
|
// @ID update-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Param request body codersdk.UpdateSkillRequest true "Update user skill request"
|
|
// @Success 200 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [patch]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) patchUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
name = chi.URLParam(r, "skillName")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
oldSkill, skill, ok := api.updateSkill(ctx, rw, r, name, userSkillUpdateErrors, func(tx database.Store, update skillUpdate) (database.Skill, database.Skill, error) {
|
|
fetched, err := tx.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return database.Skill{}, database.Skill{}, xerrors.Errorf("fetch user skill: %w", err)
|
|
}
|
|
updated, err := tx.UpdateUserSkillByUserIDAndName(ctx, database.UpdateUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
Description: update.Description,
|
|
Content: update.Content,
|
|
Enabled: update.Enabled,
|
|
})
|
|
if err != nil {
|
|
return database.Skill{}, database.Skill{}, xerrors.Errorf("update user skill: %w", err)
|
|
}
|
|
return fetched, updated, nil
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
aReq.Old = oldSkill
|
|
aReq.New = skill
|
|
}
|
|
|
|
// @Summary Delete a user skill
|
|
// @ID delete-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Success 204
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [delete]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) deleteUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
name = chi.URLParam(r, "skillName")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
deleted, err := api.Database.DeleteUserSkillByUserIDAndName(ctx, database.DeleteUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
aReq.Old = deleted
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var (
|
|
userSkillCreateErrors = skillWriteErrors{
|
|
ownerDeleted: userDeletedSkillResponse("Cannot create skills for deleted users."),
|
|
limit: skillsPerUserLimitConstraint,
|
|
limitReached: codersdk.Response{
|
|
Message: "Personal skill limit reached.",
|
|
Detail: fmt.Sprintf("Each user can have at most %d personal skills.", skills.MaxPersonalSkillsPerUser),
|
|
},
|
|
nameIndex: database.UniqueSkillsUserIDNameIndex,
|
|
}
|
|
userSkillUpdateErrors = skillWriteErrors{
|
|
ownerDeleted: userDeletedSkillResponse("Cannot modify skills for deleted users."),
|
|
}
|
|
)
|
|
|
|
func userDeletedSkillResponse(message string) codersdk.Response {
|
|
return codersdk.Response{Message: message, Detail: "This user has been deleted and cannot be modified."}
|
|
}
|
|
|
|
// skillWriteErrors holds the owner-specific responses for errors from a
|
|
// skill insert or update. Empty responses leave their error to the generic
|
|
// mapping, and an empty forbidden response uses httpapi.Forbidden.
|
|
type skillWriteErrors struct {
|
|
forbidden codersdk.Response
|
|
ownerDeleted codersdk.Response
|
|
limit database.CheckConstraint
|
|
limitReached codersdk.Response
|
|
nameIndex database.UniqueConstraint
|
|
}
|
|
|
|
func (e skillWriteErrors) write(ctx context.Context, rw http.ResponseWriter, err error) {
|
|
switch {
|
|
case httpapi.IsUnauthorizedError(err) && e.forbidden.Message == "":
|
|
httpapi.Forbidden(rw)
|
|
case httpapi.IsUnauthorizedError(err):
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, e.forbidden)
|
|
case e.ownerDeleted.Message != "" && database.IsCheckViolation(err, userSkillUserDeletedConstraint):
|
|
httpapi.Write(ctx, rw, http.StatusConflict, e.ownerDeleted)
|
|
case httpapi.Is404Error(err):
|
|
httpapi.ResourceNotFound(rw)
|
|
case e.limitReached.Message != "" && database.IsCheckViolation(err, e.limit):
|
|
httpapi.Write(ctx, rw, http.StatusConflict, e.limitReached)
|
|
case e.nameIndex != "" && database.IsUniqueViolation(err, e.nameIndex):
|
|
writeSkillNameConflict(ctx, rw)
|
|
default:
|
|
httpapi.InternalServerError(rw, err)
|
|
}
|
|
}
|
|
|
|
// createSkill parses a skill create request, stores the skill with insert,
|
|
// and writes the response. It returns false when it wrote an error.
|
|
func createSkill(
|
|
ctx context.Context,
|
|
rw http.ResponseWriter,
|
|
r *http.Request,
|
|
errs skillWriteErrors,
|
|
insert func(parsed skills.ParsedSkill, content string) (database.Skill, error),
|
|
) (database.Skill, bool) {
|
|
var req codersdk.CreateSkillRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, maxSkillRequestBytes, &req) {
|
|
return database.Skill{}, false
|
|
}
|
|
parsed, err := skills.ParsePersonalSkillMarkdown([]byte(req.Content))
|
|
if err != nil {
|
|
writeInvalidSkillContent(ctx, rw, err)
|
|
return database.Skill{}, false
|
|
}
|
|
skill, err := insert(parsed, req.Content)
|
|
if err != nil {
|
|
errs.write(ctx, rw, err)
|
|
return database.Skill{}, false
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Skill(skill))
|
|
return skill, true
|
|
}
|
|
|
|
// updateSkill applies a skill PATCH request for the skill named name and
|
|
// writes the response. apply runs in a transaction and returns the rows
|
|
// before and after the update. Callers assign audit state only after it
|
|
// returns true, so the audit log never claims a rolled-back update.
|
|
func (api *API) updateSkill(
|
|
ctx context.Context,
|
|
rw http.ResponseWriter,
|
|
r *http.Request,
|
|
name string,
|
|
errs skillWriteErrors,
|
|
apply func(tx database.Store, update skillUpdate) (old database.Skill, updated database.Skill, err error),
|
|
) (old database.Skill, updated database.Skill, ok bool) {
|
|
update, ok := readSkillUpdate(ctx, rw, r, name)
|
|
if !ok {
|
|
return database.Skill{}, database.Skill{}, false
|
|
}
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
var err error
|
|
old, updated, err = apply(tx, update)
|
|
return err
|
|
}, nil)
|
|
if err != nil {
|
|
errs.write(ctx, rw, err)
|
|
return database.Skill{}, database.Skill{}, false
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Skill(updated))
|
|
return old, updated, true
|
|
}
|
|
|
|
// skillUpdate holds the optional columns a skill PATCH writes.
|
|
type skillUpdate struct {
|
|
Description sql.NullString
|
|
Content sql.NullString
|
|
Enabled sql.NullBool
|
|
}
|
|
|
|
// readSkillUpdate reads and validates a PATCH request for the skill named
|
|
// name. It writes the error response and returns false when the request is
|
|
// invalid.
|
|
func readSkillUpdate(ctx context.Context, rw http.ResponseWriter, r *http.Request, name string) (skillUpdate, bool) {
|
|
var req codersdk.UpdateSkillRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, maxSkillRequestBytes, &req) {
|
|
return skillUpdate{}, false
|
|
}
|
|
if req.Content == nil && req.Enabled == nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "No skill fields to update.",
|
|
Detail: "Set content, enabled, or both.",
|
|
})
|
|
return skillUpdate{}, false
|
|
}
|
|
|
|
var update skillUpdate
|
|
if req.Enabled != nil {
|
|
update.Enabled = sql.NullBool{Bool: *req.Enabled, Valid: true}
|
|
}
|
|
if req.Content == nil {
|
|
return update, true
|
|
}
|
|
parsed, err := skills.ParsePersonalSkillMarkdown([]byte(*req.Content))
|
|
if err != nil {
|
|
writeInvalidSkillContent(ctx, rw, err)
|
|
return skillUpdate{}, false
|
|
}
|
|
if parsed.Name != name {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Skill name in path does not match frontmatter name.",
|
|
Detail: fmt.Sprintf("path has %q, frontmatter has %q", name, parsed.Name),
|
|
})
|
|
return skillUpdate{}, false
|
|
}
|
|
update.Description = sql.NullString{String: parsed.Description, Valid: true}
|
|
update.Content = sql.NullString{String: *req.Content, Valid: true}
|
|
return update, true
|
|
}
|
|
|
|
func writeSkillNameConflict(ctx context.Context, rw http.ResponseWriter) {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: "A skill with that name already exists.",
|
|
Detail: "Choose a different name, or edit the existing skill.",
|
|
})
|
|
}
|
|
|
|
func writeInvalidSkillContent(ctx context.Context, rw http.ResponseWriter, err error) {
|
|
message := "Invalid skill content."
|
|
switch {
|
|
case errors.Is(err, skills.ErrInvalidSkillName):
|
|
message = "Invalid skill name."
|
|
case errors.Is(err, skills.ErrSkillBodyRequired):
|
|
message = "Skill body is required."
|
|
case errors.Is(err, skills.ErrSkillTooLarge):
|
|
message = "Skill content is too large."
|
|
case errors.Is(err, skills.ErrSkillDescriptionTooLarge):
|
|
message = "Skill description is too large."
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: message,
|
|
Detail: err.Error(),
|
|
})
|
|
}
|