Files
OwnCord/Server/service/message_perms.go
T
J3vbandClaude Fable 5 67fdd18d7e feat(b2-5): one permission predicate per security property (#1440)
* feat(b2-5): canonical permission predicates

One value-taking predicate per security property in Server/permissions:
CanViewChannel, CanAdmitSession (= view), CanSendMessage, CanType (= send),
CanJoinVoice, CanModerateVoice, all over a Subject the caller resolves
(role bits, both override layers, channel flags, DM state). Checker now
resolves a Subject and asks it, so HasChannelPerm, HasChannelPermBatch and
VisibleChannelIDs are the same rule rather than three copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): send sites delegate to CanSendMessage (S-01)

checkSendPermission, HandleTyping, the ready payload's can_send and the
composer refresh all ask permissions.CanSendMessage over a resolved Subject
(PermissionService.Subject / ws subjectFor). Typing now follows the post
policy: a read-only member, an announcement reader without MANAGE_MESSAGES,
an archived channel, a blocked or non-participant DM user emit nothing.
Parity tables run each site against the predicate over the same fixture, in
both the cached-service and bare-hub branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* refactor(b2-5): view sites delegate to CanViewChannel/CanAdmitSession (S-12)

HandleChannelFocus and the post-Subscribe revalidation (applySetChannelID)
ask permissions.CanAdmitSession; channelReadAudience and
RefreshChannelVisibility ask CanViewChannel — all over a Subject resolved by
subjectFor in either the cached-service or bare-hub branch, so no ws path
mirrors the visibility rule by hand any more. hasPermChecked is gone with
its last caller. Parity tables per site, both branches, every override layer
plus an archived channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* refactor(b2-5): voice join sites delegate to CanJoinVoice

voice_join, voice_token_refresh, the destination of a moderator move and the
stale-voice sweep all ask permissions.CanJoinVoice over the subject the new
ws channelSubject resolves (role bits, both override layers, channel flags,
DM membership and block state); joinDenial maps a refusal to the frame each
reason always produced. hasChannelAccess, hasChannelAccessLive and
Hub.requireChannelAccess are gone with their last callers. The sweep now
re-runs the whole join rule (a deleted or archived channel, a lost DM
membership or a new block evict too, not only a lost CONNECT_VOICE bit), and
the token refresh refuses a deleted channel. Parity tables cover the shared
resolver, the join gate and the sweep in both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): voice moderation delegates to CanModerateVoice (SEC-02 server half)

voiceModTarget decides with permissions.CanModerateVoice over the actor's
subject in the target's channel: effective MUTE_MEMBERS there (a role-layer
or user-layer deny now holds), READ_MESSAGES so a hidden room cannot be
moderated, and DM membership for a DM call. The base-bit check stays as an
early rejection only, keeping FORBIDDEN ahead of the voice-state lookup.
Locked by a table over both override layers, a hidden channel and the
Administrator bypass, through the real voice_mod_mute path; the deafen-race
fixtures gain the Checker the gate now needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-5): evidence block, inventory and closed rows

Record the B2-5 evidence (pre-squash SHAs, before/after inventory, the
SEC-02 READ decision, the residue that leaves the authz-chokepoint rule with
B3 item 15) in the plan, mark the step done, and flip S-01, S-12 and the
server half of SEC-02 to resolved/superseded in the issue register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): CanJoinVoice refuses an archived DM call too

The old voice_join gate refused every archived channel regardless of type,
and the admin PATCH accepts archived for a DM; the predicate's DM branch
returned before consulting the flag, so join, token refresh and the sweep
would have let an evicted participant back into an archived call. Archive
is now checked after membership and block for both channel kinds (Codex P2
on #1440), pinned in the predicate table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-5): record the Codex P2 fix in the evidence block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 07:07:04 +00:00

209 lines
8.8 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// GetAccessibleChannelIDs returns all channel IDs the user can read.
func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) {
channels, err := s.st.ListChannels(ctx)
if err != nil {
return nil, fmt.Errorf("%w: failed to list channels: %v", ErrInternal, err)
}
role, err := s.perms.GetRoleForUser(ctx, userID)
if err != nil || role == nil {
return nil, fmt.Errorf("%w: failed to get role: %v", ErrInternal, err)
}
var overrides map[int64]db.ChannelOverride
if !permissions.HasAdmin(role.Permissions) {
var overrideErr error
overrides, overrideErr = s.st.GetChannelOverridesFor(ctx, role.ID, userID)
if overrideErr != nil {
return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr)
}
}
// Single visibility predicate shared with REST ListVisibleChannels and the
// ws ready payload, so no site can drift.
visibleIDs := s.perms.Checker().VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
var ids []int64
for i := range channels {
if visibleIDs[channels[i].ID] {
ids = append(ids, channels[i].ID)
}
}
// Also include DM channels the user participates in. Only the IDs are
// needed here, so skip the full DM query's preview/unread work.
//
// A failed lookup must not silently shrink the accessible set to guild
// channels only — SearchMessages (message_query.go) treats this list as
// authoritative and would otherwise report a successful, DM-stripped
// result instead of failing. Same posture as the ws sibling,
// computeAllowedChannels in ws/serve.go.
dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)
if err != nil {
return nil, fmt.Errorf("%w: failed to fetch DM channels: %v", ErrInternal, err)
}
ids = append(ids, dmIDs...)
return ids, nil
}
// CanPost reports whether userID may post into channelID, applying the same
// checks as a real message send: channel permissions via the cached checker
// for regular channels; participant membership AND block status for DMs.
// Exists so gates outside the send flow (the plugin broadcast path) share
// exactly this policy instead of hand-rolling a weaker copy.
func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error {
ch, err := s.st.GetChannel(ctx, channelID)
if err != nil || ch == nil {
return fmt.Errorf("%w: channel not found", ErrNotFound)
}
return s.checkSendPermission(ctx, userID, ch)
}
// checkSendPermission is permissions.CanSendMessage over the resolved subject:
// READ|SEND in the channel, MANAGE_MESSAGES on top for announcement channels,
// never into an archive; DM membership and no block. Every caller —
// SendMessage, EditMessage, CanPost, and typing via ChannelService — asks that
// one predicate, so none can drift (S-01, S-12).
func (s *MessageService) checkSendPermission(ctx context.Context, userID int64, ch *db.Channel) error {
sub, err := channelSubject(ctx, s.st, s.perms, userID, ch, true)
if err != nil {
return err
}
return denial(permissions.CanSendMessage(sub))
}
// channelSubject resolves what the channel predicates need for userID in ch:
// role bits and both override layers from the permission cache (a lookup
// failure or a missing role yields no bits — fail closed, as HasChannelPerm
// always has), the channel's flags, and for a DM its membership and, when
// withBlock is set, the two-party block state. The only error is a DM lookup
// failure, wrapped as ErrInternal; callers keep their own posture toward it
// (SendMessage reports it, typing drops silently).
func channelSubject(ctx context.Context, st Store, perms *PermissionService, userID int64, ch *db.Channel, withBlock bool) (permissions.Subject, error) {
sub, err := perms.Subject(ctx, userID, ch.ID)
if err != nil {
sub = permissions.Subject{}
}
sub.Channel = permissions.ChannelRef{ID: ch.ID, Type: ch.Type, Archived: ch.Archived}
if ch.Type != "dm" {
return sub, nil
}
ok, dmErr := st.IsDMParticipant(ctx, userID, ch.ID)
if dmErr != nil {
return sub, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, dmErr)
}
sub.DMParticipant = ok
if ok && withBlock {
switch blkErr := requireDMNotBlocked(ctx, st, userID, ch.ID); {
case errors.Is(blkErr, ErrBlocked):
sub.DMBlocked = true
case blkErr != nil:
return sub, blkErr
}
}
return sub, nil
}
// denial maps a predicate verdict onto the service's error kinds: a block is
// ErrBlocked (its own client-visible code), every other refusal ErrForbidden
// carrying the predicate's reason.
func denial(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, permissions.ErrBlocked):
return fmt.Errorf("%w: user is blocked", ErrBlocked)
default:
return fmt.Errorf("%w: %v", ErrForbidden, err)
}
}
// requireChannelWritable refuses a write against an archived non-DM channel.
// `archived` used to be consulted only by the visibility predicate
// (VisibleChannelIDs / RefreshChannelVisibility), so it hid a channel without
// protecting it: any caller that still held the id — a custom client, or a
// stock client racing the channel_delete that archiving triggers — could keep
// posting, editing, reacting, pinning, or bulk-deleting in an archive
// indefinitely. History stays readable; only writes are refused.
//
// DMs carry no archive flag/concept and are exempt. ch == nil is treated as
// "nothing to check" rather than a panic — the caller's own nil handling (a
// failed channel lookup) decides what happens next.
//
// Single shared gate for every write sink: checkSendPermission (so
// SendMessage, EditMessage and CanPost inherit it), plus DeleteMessage,
// handleReaction, SetMessagePinned and PurgeMessages, which route their own
// permission checks and so call it directly instead.
func requireChannelWritable(ch *db.Channel) error {
if ch == nil || ch.Type == "dm" || !ch.Archived {
return nil
}
return fmt.Errorf("%w: channel is archived", ErrForbidden)
}
// RequireDMNotBlocked is the exported form of requireDMNotBlocked so callers
// outside the service package (voice join/token-refresh, ws/voice_join.go)
// can share this single block-check implementation — same group-DM exemption,
// same "lookup failure is not a block" posture — instead of reimplementing it
// against the raw DB. st only needs to be a Store; *db.DB satisfies it.
func RequireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error {
return requireDMNotBlocked(ctx, st, userID, channelID)
}
// requireDMNotBlocked reports ErrBlocked when userID and the other participant
// of DM channelID have blocked each other in either direction.
//
// It is the single block-check implementation, called from every DM
// interaction sink — send, edit, react, pin, typing and call rings
// (DMService.RingTargets). Enforcing it on the
// send path alone left a blocked user an open channel to the blocker: editing
// an already-sent message fans MessageEditedDMEvent out to every participant,
// so arbitrary new text still reached the person who blocked them, and
// reactions and typing indicators did the same.
//
// Callers keep their own IsDMParticipant check. Its failure mode is
// deliberately different per sink (ErrForbidden for edit, ErrBadRequest for
// reactions, ErrNotFound for pins so a foreign DM's existence stays hidden)
// and flattening them here would change client-visible status codes.
//
// A GetDMRecipient lookup failure or a DM with no other participant is treated
// as "not blocked", carrying over the posture the send path has always had
// rather than newly failing closed on all five sinks at once.
//
// Group DMs are exempt, which is Discord's rule and the only coherent one for
// a shared room: there is no single "the other party" to be blocked by, and
// dropping one member's messages for one other member would leave the two of
// them reading different conversations under the same name. Blocks are instead
// enforced when the group is *created* (DMService.CreateGroupDM), where the
// question "may these two be in a room together" still has one answer.
func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error {
isGroup, gErr := st.IsGroupDM(ctx, channelID)
if gErr == nil && isGroup {
return nil
}
recipient, err := st.GetDMRecipient(ctx, channelID, userID)
if err != nil || recipient == nil {
return nil //nolint:nilerr // carries over checkSendPermission's posture: a lookup failure or a DM with no other participant is not a block
}
blocked, blkErr := st.IsEitherBlocked(ctx, userID, recipient.ID)
if blkErr != nil {
return fmt.Errorf("%w: failed to check block status: %v", ErrInternal, blkErr)
}
if blocked {
return fmt.Errorf("%w: user is blocked", ErrBlocked)
}
return nil
}