package coderd import ( "context" "database/sql" "fmt" "maps" "net/http" "slices" "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/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) // @Summary Get MCP server config ACL // @ID get-mcp-server-config-acl // @Security CoderSessionToken // @Tags MCP // @Produce json // @Param organization path string true "Organization name or ID" // @Param mcpserverconfig path string true "MCP server config ID" format(uuid) // @Success 200 {object} codersdk.MCPServerConfigACL // @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl [get] // @x-apidocgen {"skip": true} func (api *API) mcpServerConfigACL(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() config := httpmw.MCPServerConfigParam(r) // The read gate admits every ACL-granted member, so gate ACL // enumeration on the same share permission that gates updates. if !api.Authorize(r, policy.ActionShare, config.RBACObject()) { httpapi.Forbidden(rw) return } users, err := resolveACLUsers(ctx, api.Database, config.UserACL, func(user codersdk.MinimalUser, _ database.ChatACLEntry) codersdk.MCPServerConfigUser { return codersdk.MCPServerConfigUser{MinimalUser: user, Role: codersdk.MCPServerConfigRoleRead} }) if err != nil { httpapi.InternalServerError(rw, err) return } groups, err := resolveACLGroups(ctx, api.Database, uuid.Nil, config.GroupACL, func(group codersdk.Group, _ database.ChatACLEntry) codersdk.MCPServerConfigGroup { return codersdk.MCPServerConfigGroup{Group: group, Role: codersdk.MCPServerConfigRoleRead} }) if err != nil { httpapi.InternalServerError(rw, err) return } httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerConfigACL{ Users: users, Groups: groups, }) } // @Summary Get available MCP server config ACL users and groups // @ID get-available-mcp-server-config-acl-users-and-groups // @Security CoderSessionToken // @Tags MCP // @Produce json // @Param organization path string true "Organization name or ID" // @Param mcpserverconfig path string true "MCP server config ID" format(uuid) // @Param q query string false "User search query; free-text search also applies to groups" // @Param after_id query string false "User after ID" format(uuid) // @Param limit query int false "Page limit for users and groups, if 0 returns all candidates" // @Param offset query int false "User page offset" // @Success 200 {object} codersdk.ACLAvailable // @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available [get] // @x-apidocgen {"skip": true} func (api *API) mcpServerConfigACLAvailable(rw http.ResponseWriter, r *http.Request) { config := httpmw.MCPServerConfigParam(r) if !api.Authorize(r, policy.ActionShare, config.RBACObject()) { httpapi.ResourceNotFound(rw) return } api.writeOrganizationACLAvailable(rw, r, config.OrganizationID) } // writeOrganizationACLAvailable writes the organization members and groups that // can be granted access to a resource in organizationID. Callers must have // authorized share on that resource. func (api *API) writeOrganizationACLAvailable(rw http.ResponseWriter, r *http.Request, organizationID uuid.UUID) { ctx := r.Context() userFilter, validations := searchquery.Users(r.URL.Query().Get("q")) if len(validations) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid user search query.", Validations: validations, }) return } pagination, ok := ParsePagination(rw, r) if !ok { return } //nolint:gocritic // The resource share permission checked by the caller // authorizes this bounded organization-scoped lookup even when the caller // cannot browse the ordinary directories. restrictedCtx := dbauthz.AsSystemRestricted(ctx) members, err := api.Database.PaginatedOrganizationMembers(restrictedCtx, database.PaginatedOrganizationMembersParams{ AfterID: pagination.AfterID, OrganizationID: organizationID, Search: userFilter.Search, Name: userFilter.Name, ExactUsername: userFilter.ExactUsername, ExactEmail: userFilter.ExactEmail, Status: userFilter.Status, IsServiceAccount: userFilter.IsServiceAccount, RbacRole: userFilter.RbacRole, LastSeenBefore: userFilter.LastSeenBefore, LastSeenAfter: userFilter.LastSeenAfter, CreatedAfter: userFilter.CreatedAfter, CreatedBefore: userFilter.CreatedBefore, GithubComUserID: userFilter.GithubComUserID, LoginType: userFilter.LoginType, IncludeSystem: false, // #nosec G115 - Pagination offsets are small and fit in int32. OffsetOpt: int32(pagination.Offset), // #nosec G115 - Pagination limits are small and fit in int32. LimitOpt: int32(pagination.Limit), }) if err != nil { httpapi.InternalServerError(rw, xerrors.Errorf("list ACL candidate users: %w", err)) return } groups, err := api.Database.GetGroups(restrictedCtx, database.GetGroupsParams{ OrganizationID: organizationID, Search: userFilter.Search, // #nosec G115 - Pagination limits are small and fit in int32. LimitOpt: int32(pagination.Limit), }) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, xerrors.Errorf("list ACL candidate groups: %w", err)) return } groupIDs := make([]uuid.UUID, len(groups)) for i, group := range groups { groupIDs[i] = group.Group.ID } countByGroup, err := aclGroupMemberCounts(restrictedCtx, api.Database, groupIDs) if err != nil { httpapi.InternalServerError(rw, err) return } sdkUsers := make([]codersdk.ReducedUser, 0, len(members)) for _, member := range members { sdkUsers = append(sdkUsers, reducedUserFromPaginatedOrganizationMember(member)) } sdkGroups := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { sdkGroups = append(sdkGroups, db2sdk.Group(group, nil, int(countByGroup[group.Group.ID]))) } httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{ Users: sdkUsers, Groups: sdkGroups, }) } // @Summary Update MCP server config ACL // @ID update-mcp-server-config-acl // @Security CoderSessionToken // @Tags MCP // @Accept json // @Param organization path string true "Organization name or ID" // @Param mcpserverconfig path string true "MCP server config ID" format(uuid) // @Param request body codersdk.UpdateMCPServerConfigACLRequest true "Update MCP server config ACL request" // @Success 204 // @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl [patch] // @x-apidocgen {"skip": true} func (api *API) patchMCPServerConfigACL(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() config := httpmw.MCPServerConfigParam(r) apiKey := httpmw.APIKey(r) auditor := api.Auditor.Load() aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionWrite, OrganizationID: config.OrganizationID, }) defer commitAudit() aReq.Old = config if !api.Authorize(r, policy.ActionShare, config.RBACObject()) { httpapi.Forbidden(rw) return } var req codersdk.UpdateMCPServerConfigACLRequest if !httpapi.Read(ctx, rw, r, &req) { return } userRoles, groupRoles, validations := validateOrganizationACLUpdate(ctx, api.Database, config.OrganizationID, mcpServerConfigACLUpdateValidator(req)) if len(validations) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update MCP server config ACL.", Validations: validations, }) return } var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { //nolint:gocritic // The ACL write below reauthorizes the locked row for share. current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), config.ID) if err != nil { return xerrors.Errorf("get MCP server config for update: %w", err) } aReq.Old = current userACL := maps.Clone(current.UserACL) groupACL := maps.Clone(current.GroupACL) applyACLReadRoles(userACL, userRoles) applyACLReadRoles(groupACL, groupRoles) if err := tx.UpdateMCPServerConfigACLByID(ctx, database.UpdateMCPServerConfigACLByIDParams{ ID: config.ID, UserACL: userACL, GroupACL: groupACL, UpdatedBy: apiKey.UserID, }); err != nil { return xerrors.Errorf("update MCP server config ACL: %w", err) } updated = current updated.UserACL = userACL updated.GroupACL = groupACL updated.UpdatedBy = uuid.NullUUID{UUID: apiKey.UserID, Valid: true} return nil }, nil) if err != nil { // A concurrent delete between the middleware fetch and the // locked re-fetch stays concealed as 404, matching the update // and delete handlers. if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.InternalServerError(rw, err) return } aReq.New = updated rw.WriteHeader(http.StatusNoContent) } // resolveACLUsers resolves user ACL entries for callers that already // authorized reading the resource's ACL. func resolveACLUsers[T any](ctx context.Context, db database.Store, entries database.ChatACL, entry func(codersdk.MinimalUser, database.ChatACLEntry) T) ([]T, error) { ids := parseACLIDs(entries) //nolint:gocritic // Callers that can read the ACL see shared users without user read permission. users, err := db.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), ids) if err != nil { return nil, xerrors.Errorf("get ACL users: %w", err) } result := make([]T, 0, len(users)) for _, user := range users { result = append(result, entry(db2sdk.MinimalUser(user), entries[user.ID.String()])) } return result, nil } // resolveACLGroups resolves group ACL entries, including member counts, for // callers that already authorized reading the resource's ACL. Unless // organizationID is uuid.Nil, groups from other organizations are left out. func resolveACLGroups[T any](ctx context.Context, db database.Store, organizationID uuid.UUID, entries database.ChatACL, entry func(codersdk.Group, database.ChatACLEntry) T) ([]T, error) { ids := parseACLIDs(entries) var groups []database.GetGroupsRow if len(ids) > 0 { var err error //nolint:gocritic // Callers that can read the ACL see shared groups without group read permission. groups, err = db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{OrganizationID: organizationID, GroupIds: ids}) if err != nil { return nil, xerrors.Errorf("get ACL groups: %w", err) } } groupIDs := make([]uuid.UUID, 0, len(groups)) for _, group := range groups { groupIDs = append(groupIDs, group.Group.ID) } //nolint:gocritic // Callers that can read the ACL see shared group sizes without group read permission. countByGroup, err := aclGroupMemberCounts(dbauthz.AsSystemRestricted(ctx), db, groupIDs) if err != nil { return nil, err } result := make([]T, 0, len(groups)) for _, group := range groups { result = append(result, entry(db2sdk.Group(group, nil, int(countByGroup[group.Group.ID])), entries[group.Group.ID.String()])) } return result, nil } func aclGroupMemberCounts(ctx context.Context, db database.Store, groupIDs []uuid.UUID) (map[uuid.UUID]int64, error) { countByGroup := make(map[uuid.UUID]int64, len(groupIDs)) if len(groupIDs) == 0 { return countByGroup, nil } countRows, err := db.GetGroupMembersCountByGroupIDs(ctx, database.GetGroupMembersCountByGroupIDsParams{ GroupIds: groupIDs, IncludeSystem: false, }) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { return nil, xerrors.Errorf("count ACL group members: %w", err) } for _, row := range countRows { countByGroup[row.GroupID] = row.MemberCount } return countByGroup, nil } // validateOrganizationACLUpdate validates a sparse ACL update for a resource // in organizationID and returns its roles rekeyed by canonical UUID. func validateOrganizationACLUpdate[R acl.Role](ctx context.Context, db database.Store, organizationID uuid.UUID, v acl.UpdateValidator[R]) (userRoles, groupRoles map[string]R, validations []codersdk.ValidationError) { users, usersField := v.Users() groups, groupsField := v.Groups() validations = acl.Validate(ctx, db, v) validations = append(validations, validateACLOrganization(ctx, db, organizationID, usersField, users, groupsField, groups)...) userRoles, dupErrs := canonicalACLRoles(usersField, users) validations = append(validations, dupErrs...) groupRoles, dupErrs = canonicalACLRoles(groupsField, groups) validations = append(validations, dupErrs...) return userRoles, groupRoles, validations } // canonicalACLRoles rekeys the request map by canonical uuid.String() values // so noncanonical spellings hit the same keys RBAC reads, and rejects requests // where two spellings collapse to one principal because map order would // decide which role wins. Unparsable keys are skipped; acl.Validate already // reports them. func canonicalACLRoles[R acl.Role](field string, roles map[string]R) (map[string]R, []codersdk.ValidationError) { canonical := make(map[string]R, len(roles)) var validErrs []codersdk.ValidationError for rawID, role := range roles { parsed, err := uuid.Parse(rawID) if err != nil { continue } id := parsed.String() if _, ok := canonical[id]; ok { validErrs = append(validErrs, codersdk.ValidationError{ Field: field, Detail: fmt.Sprintf("duplicate entries for ID %s", id), }) continue } canonical[id] = role } return canonical, validErrs } // applyACLReadRoles applies sparse roles to entries: the empty role removes an // entry and every other validated role grants read. func applyACLReadRoles[R acl.Role](entries database.ChatACL, roles map[string]R) { for id, role := range roles { if string(role) == "" { delete(entries, id) continue } entries[id] = database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}} } } func parseACLIDs(entries database.ChatACL) []uuid.UUID { ids := make([]uuid.UUID, 0, len(entries)) for rawID := range entries { if id, err := uuid.Parse(rawID); err == nil { ids = append(ids, id) } } return ids } func validateACLOrganization[R acl.Role](ctx context.Context, db database.Store, organizationID uuid.UUID, usersField string, userRoles map[string]R, groupsField string, groupRoles map[string]R) []codersdk.ValidationError { var validations []codersdk.ValidationError userIDs := activeACLIDs(userRoles) if len(userIDs) > 0 { //nolint:gocritic // Principal validation requires organization membership visibility. memberships, err := db.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs) if err != nil { return append(validations, codersdk.ValidationError{Field: usersField, Detail: err.Error()}) } byUser := make(map[uuid.UUID][]uuid.UUID, len(memberships)) for _, membership := range memberships { byUser[membership.UserID] = membership.OrganizationIDs } for _, id := range userIDs { if !slices.Contains(byUser[id], organizationID) { validations = append(validations, codersdk.ValidationError{ Field: usersField, Detail: "user " + id.String() + " does not belong to organization " + organizationID.String(), }) } } } groupIDs := activeACLIDs(groupRoles) if len(groupIDs) > 0 { //nolint:gocritic // Principal validation requires group organization visibility. groups, err := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) if err != nil { return append(validations, codersdk.ValidationError{Field: groupsField, Detail: err.Error()}) } for _, group := range groups { if group.Group.OrganizationID != organizationID { validations = append(validations, codersdk.ValidationError{ Field: groupsField, Detail: "group " + group.Group.ID.String() + " does not belong to organization " + organizationID.String(), }) } } } return validations } func activeACLIDs[R acl.Role](roles map[string]R) []uuid.UUID { ids := make([]uuid.UUID, 0, len(roles)) for rawID, role := range roles { if string(role) == "" { continue } if id, err := uuid.Parse(rawID); err == nil { ids = append(ids, id) } } return ids } type mcpServerConfigACLUpdateValidator codersdk.UpdateMCPServerConfigACLRequest var _ acl.UpdateValidator[codersdk.MCPServerConfigRole] = mcpServerConfigACLUpdateValidator{} func (m mcpServerConfigACLUpdateValidator) Users() (map[string]codersdk.MCPServerConfigRole, string) { return m.UserRoles, "user_roles" } func (m mcpServerConfigACLUpdateValidator) Groups() (map[string]codersdk.MCPServerConfigRole, string) { return m.GroupRoles, "group_roles" } func (mcpServerConfigACLUpdateValidator) ValidateRole(role codersdk.MCPServerConfigRole) error { if role == codersdk.MCPServerConfigRoleDeleted || role == codersdk.MCPServerConfigRoleRead { return nil } return xerrors.Errorf("role %q is not a valid MCP server config role", role) }