Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,7 @@ func New(options *Options) *API {
// organization member. This endpoint should match the authz story of
// postWorkspacesByOrganization
r.Post("/workspaces", api.postUserWorkspaces)
r.Post("/chats", api.postUserChats)
r.Route("/workspace/{workspacename}", func(r chi.Router) {
r.Get("/", api.workspaceByOwnerAndName)
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
Expand Down
98 changes: 77 additions & 21 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,53 @@ func invalidChatMCPServerIDsResponse(ids []uuid.UUID) codersdk.Response {
// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB"
// @Router /api/v2/chats [post]
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
api.createChat(rw, r, func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
isMember, err := httpmw.UserAuthorization(ctx).HasOrganizationMembership(organizationID)
if err != nil {
return uuid.Nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: xerrors.Errorf("check organization membership: %w", err).Error(),
})
}
if !isMember {
return uuid.Nil, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "You are not a member of the specified organization.",
})
}
return apiKey.UserID, nil
})
}

// @Summary Create user chat
// @ID create-user-chat
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param user path string true "Username, UUID, or me"
// @Param request body codersdk.CreateChatRequest true "Create chat request"
// @Success 201 {object} codersdk.Chat
// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB"
// @Router /api/v2/users/{user}/chats [post]
func (api *API) postUserChats(rw http.ResponseWriter, r *http.Request) {
mems := httpmw.OrganizationMembersParam(r)
api.createChat(rw, r, func(_ context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
// The memberships are already limited to what the caller may read,
// so a missing match stays a vague 404 like postUserWorkspaces.
idx := slices.IndexFunc(mems.Memberships, func(member httpmw.OrganizationMember) bool {
return member.OrganizationID == organizationID
})
if idx == -1 {
return uuid.Nil, httperror.ErrResourceNotFound
}
return mems.Memberships[idx].UserID, nil
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
})
}

// createChat backs both chat creation endpoints. resolveOwner runs after the
// body is parsed because the owner depends on req.OrganizationID.
func (api *API) createChat(rw http.ResponseWriter, r *http.Request, resolveOwner func(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)

Expand All @@ -1223,51 +1270,61 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
})
defer commitAudit()

// Validate organization membership.
if req.OrganizationID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "organization_id is required.",
})
return
}
isMember, err := httpmw.UserAuthorization(ctx).HasOrganizationMembership(req.OrganizationID)
ownerID, err := resolveOwner(ctx, req.OrganizationID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: xerrors.Errorf("check organization membership: %w", err).Error(),
})
return
}
if !isMember {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "You are not a member of the specified organization.",
})
httperror.WriteResponseError(ctx, rw, err)
return
}
// NOTE: This authorize check is intentionally placed after request
// parsing because we need req.OrganizationID to scope the RBAC check
// to the correct org. The request body is bounded by the ReadLimit above,
// limiting the cost of parsing before rejection.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(req.OrganizationID)) {
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(ownerID.String()).InOrg(req.OrganizationID)) {
httpapi.Forbidden(rw)
return
}
// Chat processing runs with the owner's credentials (workspace access,
// OIDC and provider tokens), so creating a chat for another user is
// acting as that user. Org-scoped chat permissions are not enough:
// require the same authority the token endpoint demands to mint a
// session for that user.
ownerCtx := ctx
if ownerID != apiKey.UserID {
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceApiKey.WithOwner(ownerID.String())) {
httpapi.Forbidden(rw)
return
}
owner, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// The workspace must be usable by the owner, who is the one the
// chat will connect as, not merely visible to the caller.
ownerCtx = dbauthz.As(ctx, owner)
}

contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
}

workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ctx, r, req)
workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ownerCtx, req)
if validationError != nil {
httpapi.Write(ctx, rw, validationStatus, *validationError)
return
}

title := chatprompt.FallbackTitle(titleSource)

modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, req)
modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, ownerID, req)
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
if modelConfigError != nil {
httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError)
return
Expand Down Expand Up @@ -1378,7 +1435,8 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {

chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: req.OrganizationID,
OwnerID: apiKey.UserID,
OwnerID: ownerID,
CreatedBy: apiKey.UserID,
Comment thread
ibetitsmike marked this conversation as resolved.
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
TitleDerivedFromContent: true,
Expand Down Expand Up @@ -2443,7 +2501,7 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
if *req.WorkspaceID != uuid.Nil {
var status int
var resp *codersdk.Response
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
Expand Down Expand Up @@ -4164,7 +4222,6 @@ type createChatWorkspaceSelection struct {

func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
workspaceID *uuid.UUID,
) (
uuid.NullUUID,
Expand Down Expand Up @@ -4193,7 +4250,7 @@ func (api *API) validateChatWorkspaceSelection(
UUID: workspace.ID,
Valid: true,
}
if !api.Authorize(r, policy.ActionSSH, workspace) {
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionSSH, workspace) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
Expand All @@ -4204,15 +4261,14 @@ func (api *API) validateChatWorkspaceSelection(

func (api *API) validateCreateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
req codersdk.CreateChatRequest,
) (
createChatWorkspaceSelection,
int,
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
return selection, status, resp
}
Expand Down
Loading
Loading