Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/server-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# - server-ci-report.yml
# - sentry.yaml
# If you rename this workflow, be sure to update those workflows as well.
name: Server CI

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"
on:

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]
workflow_dispatch: # Allow manual/API triggering for linked plugin CI
push:
branches:
- master
Expand Down Expand Up @@ -36,14 +37,14 @@
gomod-changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 40 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

40:73 [comments] too few spaces before comment: expected 2

Check warning on line 40 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

40:73 [comments] too few spaces before comment: expected 2
- name: Calculate version
id: calculate
working-directory: server/
run: echo GO_VERSION=$(cat .go-version) >> "${GITHUB_OUTPUT}"
- name: Check for go.mod changes
id: changed-files
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5

Check warning on line 47 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

47:81 [comments] too few spaces before comment: expected 2

Check warning on line 47 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

47:81 [comments] too few spaces before comment: expected 2
with:
files: |
**/go.mod
Expand All @@ -57,7 +58,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 61 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

61:73 [comments] too few spaces before comment: expected 2

Check warning on line 61 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

61:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Generate mocks
Expand All @@ -74,7 +75,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 78 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

78:73 [comments] too few spaces before comment: expected 2

Check warning on line 78 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

78:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Run go mod tidy
Expand Down
145 changes: 144 additions & 1 deletion server/channels/api4/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ func (api *API) InitChannel() {

api.BaseRoutes.ChannelModerations.Handle("", api.APISessionRequired(getChannelModerations)).Methods(http.MethodGet)
api.BaseRoutes.ChannelModerations.Handle("/patch", api.APISessionRequired(patchChannelModerations)).Methods(http.MethodPut)

api.initChannelJoinRequestRoutes()
}

func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -144,6 +146,24 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}

if channel.Discoverable {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError("createChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.Type != model.ChannelTypePrivate {
c.Err = model.NewAppError("createChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
return
}
// The team-scoped check is the closest analog to "would this user
// have permission to manage discoverability after the channel is
// created" — channel-scope grants don't exist yet at creation time.
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManagePrivateChannelDiscoverability) {
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
return
}
}

sc, appErr := c.App.CreateChannelWithUser(c.AppContext, channel, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
Expand Down Expand Up @@ -377,12 +397,36 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil || patch.DefaultCategoryName != nil
updatingAutoTranslation := patch.AutoTranslation != nil
updatingManagedCategory := patch.ManagedCategoryName != nil
updatingDiscoverable := patch.Discoverable != nil

if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory {
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory && !updatingDiscoverable {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.no_changes.app_error", nil, "", http.StatusBadRequest)
return
}

if updatingDiscoverable {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.Type != model.ChannelTypePrivate {
c.Err = model.NewAppError("patchChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.DeleteAt != 0 {
c.Err = model.NewAppError("patchChannel", "api.channel.update_channel.deleted.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.IsShared() {
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "", http.StatusBadRequest)
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelDiscoverability); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
return
}
}

if updatingAutoTranslation && (c.App.AutoTranslation() == nil || !c.App.AutoTranslation().IsFeatureAvailable()) {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.feature_not_available.app_error", nil, "", http.StatusForbidden)
return
Expand Down Expand Up @@ -806,6 +850,9 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
} else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.SetPermissionError(model.PermissionReadChannel)
return
}
Expand All @@ -822,6 +869,80 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}

// sanitizeDiscoverableChannel returns a copy of `channel` containing only the
// fields safe to expose to a non-member who can see the channel through the
// discoverable surface. Cell-level secrets such as Props or per-channel
// scheme identifiers are stripped so this view is strictly read-only metadata.
func sanitizeDiscoverableChannel(channel *model.Channel) *model.Channel {
if channel == nil {
return nil
}
return &model.Channel{
Id: channel.Id,
TeamId: channel.TeamId,
Type: channel.Type,
DisplayName: channel.DisplayName,
Name: channel.Name,
Header: channel.Header,
Purpose: channel.Purpose,
Discoverable: channel.Discoverable,
PolicyEnforced: channel.PolicyEnforced,
CreateAt: channel.CreateAt,
UpdateAt: channel.UpdateAt,
DeleteAt: channel.DeleteAt,
}
}

// discoverableNonMemberView returns a sanitized non-member view of `channel`
// when the calling user qualifies under the discoverable visibility rules,
// or (nil, nil) when the channel must remain hidden — the caller should
// emit its own permission-denied response. Errors from the discoverable
// lookup are returned for the caller to assign to c.Err. When the feature
// flag is off, this returns (nil, nil) and the caller falls through to its
// default 403/404 path so the existing read contract is preserved.
func discoverableNonMemberView(c *Context, channel *model.Channel) (*model.Channel, *model.AppError) {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
return nil, nil
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
return nil, userErr
}
allowed, allowedErr := c.App.IsDiscoverableJoinAllowed(c.AppContext, user, channel)
if allowedErr != nil {
return nil, allowedErr
}
if !allowed {
return nil, nil
}
return sanitizeDiscoverableChannel(channel), nil
}

// serveDiscoverableNonMember writes the sanitized non-member discoverable
// view of `channel` to `w` and returns true when the request was handled
// here (either the response was written, or c.Err was set on a lookup
// failure). Returns false without touching the response when the caller
// should emit its own permission-denied response (the channel is hidden
// from this non-member, or the feature flag is off).
//
// Centralising this here means every read endpoint that previously emitted
// 403/404 to a non-member can keep its prior failure shape while opting in
// to the discoverable surface with a single `if served { return }` guard.
func serveDiscoverableNonMember(c *Context, w http.ResponseWriter, channel *model.Channel) bool {
sanitized, err := discoverableNonMemberView(c, channel)
if err != nil {
c.Err = err
return true
}
if sanitized == nil {
return false
}
if encErr := json.NewEncoder(w).Encode(sanitized); encErr != nil {
c.Logger.Warn("Error while writing response", mlog.Err(encErr))
}
return true
}

func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId().RequireUserId()
if c.Err != nil {
Expand Down Expand Up @@ -1646,6 +1767,9 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
// allows team admins to access private channel
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
}
Expand Down Expand Up @@ -1686,6 +1810,9 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ
} else if !channelOk {
// allows team admins to access private channel
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.Err = model.NewAppError("getChannelByNameForTeamName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
}
Expand Down Expand Up @@ -2252,9 +2379,25 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {

if channel.Type == model.ChannelTypePrivate {
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission {
// Allow the user to self-add to a discoverable private channel only
// through the request flow — the discoverable toggle does not
// implicitly grant PermissionManagePrivateChannelMembers, and the
// existing addChannelMember API would otherwise let any caller
// bypass the queue by issuing a direct POST.
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
return
}

// Discoverable + no policy: the request flow is the only path. Even
// admins use it to ensure the audit trail. We exempt the case where
// the requester is adding someone other than themselves so admin
// invites still work.
for _, userId := range userIds {
if c.App.IsDiscoverableSelfAddBlocked(c.AppContext, channel, c.AppContext.Session().UserId, userId) {
c.Err = model.NewAppError("addChannelMember", "api.channel.discoverable_join_request.discoverable_requires_approval.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
return
}
}
}

if channel.IsGroupConstrained() {
Expand Down
Loading
Loading