Files
coder/coderd/exp_chats_acl.go
2026-05-20 10:46:35 +01:00

316 lines
9.3 KiB
Go

package coderd
import (
"context"
"database/sql"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
slog "cdr.dev/slog/v3"
"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"
"github.com/coder/coder/v2/coderd/rbac/acl"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get chat ACLs
// @ID get-chat-acls
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ChatACL
// @Router /api/experimental/chats/{chat}/acl [get]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.allowChatSharing(ctx, rw) {
return
}
if chat.IsSubChat() {
resp := codersdk.Response{Message: "Chat ACLs can only be set on root chats."}
if chat.RootChatID.Valid {
resp.Detail = "Target the root chat (id: " + chat.RootChatID.UUID.String() + ") instead."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
chatACL, err := api.Database.GetChatACLByID(ctx, chat.ID)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
users, ok := api.chatACLUsers(ctx, rw, chat, chatACL.Users)
if !ok {
return
}
groups, ok := api.chatACLGroups(ctx, rw, chat, chatACL.Groups)
if !ok {
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatACL{
Users: users,
Groups: groups,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Update chat ACL
// @ID update-chat-acl
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.UpdateChatACL true "Update chat ACL request"
// @Success 204
// @Router /api/experimental/chats/{chat}/acl [patch]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: chat.OrganizationID,
})
defer commitAudit()
aReq.Old = chat
if !api.allowChatSharing(ctx, rw) {
return
}
if chat.IsSubChat() {
resp := codersdk.Response{Message: "Chat ACLs can only be set on root chats."}
if chat.RootChatID.Valid {
resp.Detail = "Target the root chat (id: " + chat.RootChatID.UUID.String() + ") instead."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
if !api.Authorize(r, policy.ActionShare, chat.RBACObject()) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatACL
if !httpapi.Read(ctx, rw, r, &req) {
return
}
apiKey := httpmw.APIKey(r)
for userID := range req.UserRoles {
parsed, err := uuid.Parse(userID)
if err == nil && parsed == apiKey.UserID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot change your own chat sharing role.",
})
return
}
}
validErrs := acl.Validate(ctx, api.Database, ChatACLUpdateValidator(req))
if len(validErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request to update chat ACL.",
Validations: validErrs,
})
return
}
err := api.Database.InTx(func(tx database.Store) error {
current, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get chat by ID: %w", err)
}
if current.UserACL == nil {
current.UserACL = database.ChatACL{}
}
if current.GroupACL == nil {
current.GroupACL = database.ChatACL{}
}
for id, role := range req.UserRoles {
if role == codersdk.ChatRoleDeleted {
delete(current.UserACL, id)
continue
}
current.UserACL[id] = database.ChatACLEntry{
Permissions: db2sdk.ChatRoleActions(role),
}
}
for id, role := range req.GroupRoles {
if role == codersdk.ChatRoleDeleted {
delete(current.GroupACL, id)
continue
}
current.GroupACL[id] = database.ChatACLEntry{
Permissions: db2sdk.ChatRoleActions(role),
}
}
if err := tx.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: current.UserACL,
GroupACL: current.GroupACL,
}); err != nil {
return xerrors.Errorf("update chat ACL: %w", err)
}
updatedChat, err := tx.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get updated chat by ID: %w", err)
}
aReq.New = updatedChat
return nil
}, nil)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) {
userIDs := make([]uuid.UUID, 0, len(entries))
for userID := range entries {
id, err := uuid.Parse(userID)
if err != nil {
api.Logger.Warn(ctx, "found invalid user uuid in chat acl", slog.Error(err), slog.F("chat_id", chat.ID))
continue
}
userIDs = append(userIDs, id)
}
//nolint:gocritic // Users who can read the chat ACL should see shared users even without user read permission.
dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return nil, false
}
users := make([]codersdk.ChatUser, 0, len(dbUsers))
for _, user := range dbUsers {
entry := entries[user.ID.String()]
users = append(users, codersdk.ChatUser{
MinimalUser: db2sdk.MinimalUser(user),
Role: convertToChatRole(entry.Permissions),
})
}
return users, true
}
func (api *API) chatACLGroups(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatGroup, bool) {
groupIDs := make([]uuid.UUID, 0, len(entries))
for groupID := range entries {
id, err := uuid.Parse(groupID)
if err != nil {
api.Logger.Warn(ctx, "found invalid group uuid in chat acl", slog.Error(err), slog.F("chat_id", chat.ID))
continue
}
groupIDs = append(groupIDs, id)
}
dbGroups := make([]database.GetGroupsRow, 0)
if len(groupIDs) > 0 {
var err error
//nolint:gocritic // Users who can read the chat ACL should see shared groups even without group read permission.
dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return nil, false
}
}
groups := make([]codersdk.ChatGroup, 0, len(dbGroups))
for _, group := range dbGroups {
//nolint:gocritic // Users who can read the chat ACL should see shared group sizes even without group read permission.
memberCount, err := api.Database.GetGroupMembersCountByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return nil, false
}
entry := entries[group.Group.ID.String()]
groups = append(groups, codersdk.ChatGroup{
Group: db2sdk.Group(group, nil, int(memberCount)),
Role: convertToChatRole(entry.Permissions),
})
}
return groups, true
}
func (api *API) allowChatSharing(ctx context.Context, rw http.ResponseWriter) bool {
if !api.chatSharingDisabled() {
return true
}
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Chat sharing is disabled for this deployment.",
})
return false
}
func (api *API) chatSharingDisabled() bool {
return rbac.ChatACLDisabled() || (api.DeploymentValues != nil && bool(api.DeploymentValues.DisableChatSharing))
}
type ChatACLUpdateValidator codersdk.UpdateChatACL
var _ acl.UpdateValidator[codersdk.ChatRole] = ChatACLUpdateValidator{}
func (c ChatACLUpdateValidator) Users() (map[string]codersdk.ChatRole, string) {
return c.UserRoles, "user_roles"
}
func (c ChatACLUpdateValidator) Groups() (map[string]codersdk.ChatRole, string) {
return c.GroupRoles, "group_roles"
}
func (ChatACLUpdateValidator) ValidateRole(role codersdk.ChatRole) error {
if role == codersdk.ChatRoleDeleted || role == codersdk.ChatRoleRead {
return nil
}
return xerrors.Errorf("role %q is not a valid chat role", role)
}
func convertToChatRole(actions []policy.Action) codersdk.ChatRole {
if slice.SameElements(actions, db2sdk.ChatRoleActions(codersdk.ChatRoleRead)) {
return codersdk.ChatRoleRead
}
return codersdk.ChatRoleDeleted
}