mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
150 lines
9.5 KiB
Go
150 lines
9.5 KiB
Go
package permissions
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// Each predicate is one security property. The tables below are the property
|
|
// stated as cases; every call site that used to hand-roll a copy of the rule
|
|
// now delegates here and carries a parity test against these verdicts.
|
|
|
|
const (
|
|
memberBits = ReadMessages | SendMessages | ConnectVoice
|
|
modBits = memberBits | ManageMessages | MuteMembers
|
|
)
|
|
|
|
func text(archived bool) ChannelRef { return ChannelRef{Type: "text", Archived: archived} }
|
|
func announcement() ChannelRef { return ChannelRef{Type: "announcement"} }
|
|
func voice(archived bool) ChannelRef { return ChannelRef{Type: "voice", Archived: archived} }
|
|
func dm() ChannelRef { return ChannelRef{Type: "dm"} }
|
|
func deny(bits int64) ChannelOverride { return ChannelOverride{Deny: bits} }
|
|
func userDeny(bits int64) ChannelOverride { return ChannelOverride{UserDeny: bits} }
|
|
func allow(bits int64) ChannelOverride { return ChannelOverride{Allow: bits} }
|
|
func userAllow(bits int64) ChannelOverride { return ChannelOverride{UserAllow: bits} }
|
|
|
|
type predicateCase struct {
|
|
name string
|
|
s Subject
|
|
want error // nil = allowed; otherwise errors.Is(got, want)
|
|
}
|
|
|
|
func runPredicate(t *testing.T, name string, fn func(Subject) error, cases []predicateCase) {
|
|
t.Helper()
|
|
for _, c := range cases {
|
|
got := fn(c.s)
|
|
if c.want == nil && got != nil {
|
|
t.Errorf("%s/%s: want allowed, got %v", name, c.name, got)
|
|
}
|
|
if c.want != nil && !errors.Is(got, c.want) {
|
|
t.Errorf("%s/%s: want %v, got %v", name, c.name, c.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCanViewChannel(t *testing.T) {
|
|
runPredicate(t, "CanViewChannel", CanViewChannel, []predicateCase{
|
|
{"member reads text", Subject{RolePerms: memberBits, Channel: text(false)}, nil},
|
|
{"no role fails closed", Subject{Channel: text(false)}, ErrPermissionDenied},
|
|
{"role deny READ hides", Subject{RolePerms: memberBits, Override: deny(ReadMessages), Channel: text(false)}, ErrPermissionDenied},
|
|
{"user deny READ hides", Subject{RolePerms: memberBits, Override: userDeny(ReadMessages), Channel: text(false)}, ErrPermissionDenied},
|
|
{"user allow beats role deny", Subject{RolePerms: memberBits, Override: ChannelOverride{Deny: ReadMessages, UserAllow: ReadMessages}, Channel: text(false)}, nil},
|
|
{"admin bypasses deny", Subject{RolePerms: Administrator, Override: deny(ReadMessages), Channel: text(false)}, nil},
|
|
{"archived hidden from members", Subject{RolePerms: memberBits, Channel: text(true)}, ErrArchived},
|
|
{"archived hidden from admins", Subject{RolePerms: Administrator, Channel: text(true)}, ErrArchived},
|
|
{"unauthorized never learns archived", Subject{Channel: text(true)}, ErrPermissionDenied},
|
|
{"dm participant sees", Subject{Channel: dm(), DMParticipant: true}, nil},
|
|
{"dm non-participant blind, even admin", Subject{RolePerms: Administrator, Channel: dm()}, ErrNotDMParticipant},
|
|
{"dm ignores block", Subject{Channel: dm(), DMParticipant: true, DMBlocked: true}, nil},
|
|
})
|
|
}
|
|
|
|
func TestCanSendMessage(t *testing.T) {
|
|
cases := []predicateCase{
|
|
{"member posts in text", Subject{RolePerms: memberBits, Channel: text(false)}, nil},
|
|
{"reader without SEND refused", Subject{RolePerms: ReadMessages, Channel: text(false)}, ErrPermissionDenied},
|
|
{"SEND without READ refused", Subject{RolePerms: SendMessages, Channel: text(false)}, ErrPermissionDenied},
|
|
{"role deny SEND refused", Subject{RolePerms: memberBits, Override: deny(SendMessages), Channel: text(false)}, ErrPermissionDenied},
|
|
{"user deny SEND refused", Subject{RolePerms: memberBits, Override: userDeny(SendMessages), Channel: text(false)}, ErrPermissionDenied},
|
|
{"announcement needs MANAGE", Subject{RolePerms: memberBits, Channel: announcement()}, ErrPermissionDenied},
|
|
{"moderator posts in announcement", Subject{RolePerms: modBits, Channel: announcement()}, nil},
|
|
{"override allow MANAGE enables announcement", Subject{RolePerms: memberBits, Override: allow(ManageMessages), Channel: announcement()}, nil},
|
|
{"user allow MANAGE enables announcement", Subject{RolePerms: memberBits, Override: userAllow(ManageMessages), Channel: announcement()}, nil},
|
|
{"admin bypasses on announcement", Subject{RolePerms: Administrator, Channel: announcement()}, nil},
|
|
{"archived refuses members", Subject{RolePerms: memberBits, Channel: text(true)}, ErrArchived},
|
|
{"archived refuses admins", Subject{RolePerms: Administrator, Channel: text(true)}, ErrArchived},
|
|
{"unauthorized never learns archived", Subject{RolePerms: ReadMessages, Channel: text(true)}, ErrPermissionDenied},
|
|
{"dm participant posts without role bits", Subject{Channel: dm(), DMParticipant: true}, nil},
|
|
{"dm non-participant refused", Subject{RolePerms: Administrator, Channel: dm()}, ErrNotDMParticipant},
|
|
{"dm blocked refused", Subject{Channel: dm(), DMParticipant: true, DMBlocked: true}, ErrBlocked},
|
|
}
|
|
runPredicate(t, "CanSendMessage", CanSendMessage, cases)
|
|
// CanType is CanSendMessage by definition (S-01): a typing indicator is
|
|
// the first half of a post, so it answers to the same rule.
|
|
runPredicate(t, "CanType", CanType, cases)
|
|
}
|
|
|
|
func TestCanAdmitSession(t *testing.T) {
|
|
// Session admission (channel_focus / topic subscribe) is visibility.
|
|
cases := []predicateCase{
|
|
{"member admitted", Subject{RolePerms: memberBits, Channel: text(false)}, nil},
|
|
{"archived refused", Subject{RolePerms: memberBits, Channel: text(true)}, ErrArchived},
|
|
{"dm participant admitted", Subject{Channel: dm(), DMParticipant: true}, nil},
|
|
{"dm non-participant refused", Subject{Channel: dm()}, ErrNotDMParticipant},
|
|
}
|
|
runPredicate(t, "CanAdmitSession", CanAdmitSession, cases)
|
|
runPredicate(t, "CanViewChannel(parity)", CanViewChannel, cases)
|
|
}
|
|
|
|
func TestCanJoinVoice(t *testing.T) {
|
|
runPredicate(t, "CanJoinVoice", CanJoinVoice, []predicateCase{
|
|
{"member joins voice", Subject{RolePerms: memberBits, Channel: voice(false)}, nil},
|
|
{"no CONNECT refused", Subject{RolePerms: ReadMessages, Channel: voice(false)}, ErrPermissionDenied},
|
|
{"role deny CONNECT refused", Subject{RolePerms: memberBits, Override: deny(ConnectVoice), Channel: voice(false)}, ErrPermissionDenied},
|
|
{"user deny CONNECT refused", Subject{RolePerms: memberBits, Override: userDeny(ConnectVoice), Channel: voice(false)}, ErrPermissionDenied},
|
|
{"admin bypasses deny", Subject{RolePerms: Administrator, Override: deny(ConnectVoice), Channel: voice(false)}, nil},
|
|
{"text channel is not voice", Subject{RolePerms: memberBits, Channel: text(false)}, ErrNotVoiceChannel},
|
|
{"archived voice refused", Subject{RolePerms: memberBits, Channel: voice(true)}, ErrArchived},
|
|
{"unauthorized never learns archived", Subject{RolePerms: ReadMessages, Channel: voice(true)}, ErrPermissionDenied},
|
|
{"dm call needs CONNECT bit too", Subject{Channel: dm(), DMParticipant: true}, ErrPermissionDenied},
|
|
{"dm participant with CONNECT joins", Subject{RolePerms: ConnectVoice, Channel: dm(), DMParticipant: true}, nil},
|
|
{"dm non-participant refused", Subject{RolePerms: Administrator, Channel: dm()}, ErrNotDMParticipant},
|
|
{"dm blocked refused", Subject{RolePerms: ConnectVoice, Channel: dm(), DMParticipant: true, DMBlocked: true}, ErrBlocked},
|
|
// The admin PATCH accepts archived for any channel type, and the old
|
|
// voice_join gate refused every archived channel — an evicted
|
|
// participant must not rejoin an archived DM call (Codex P2, #1440).
|
|
{"archived dm call refused", Subject{RolePerms: ConnectVoice, Channel: ChannelRef{Type: "dm", Archived: true}, DMParticipant: true}, ErrArchived},
|
|
{"archived dm non-participant learns nothing", Subject{RolePerms: ConnectVoice, Channel: ChannelRef{Type: "dm", Archived: true}}, ErrNotDMParticipant},
|
|
})
|
|
}
|
|
|
|
func TestCanModerateVoice(t *testing.T) {
|
|
runPredicate(t, "CanModerateVoice", CanModerateVoice, []predicateCase{
|
|
{"moderator with MUTE in channel", Subject{RolePerms: modBits, Channel: voice(false)}, nil},
|
|
{"no MUTE refused", Subject{RolePerms: memberBits, Channel: voice(false)}, ErrPermissionDenied},
|
|
{"role deny MUTE in this channel refused", Subject{RolePerms: modBits, Override: deny(MuteMembers), Channel: voice(false)}, ErrPermissionDenied},
|
|
{"user deny MUTE in this channel refused", Subject{RolePerms: modBits, Override: userDeny(MuteMembers), Channel: voice(false)}, ErrPermissionDenied},
|
|
{"channel hidden from moderator refused", Subject{RolePerms: modBits, Override: deny(ReadMessages), Channel: voice(false)}, ErrPermissionDenied},
|
|
{"admin bypasses deny", Subject{RolePerms: Administrator, Override: deny(MuteMembers), Channel: voice(false)}, nil},
|
|
{"dm call needs actor membership", Subject{RolePerms: modBits, Channel: dm()}, ErrNotDMParticipant},
|
|
{"dm participant moderator allowed", Subject{RolePerms: modBits, Channel: dm(), DMParticipant: true}, nil},
|
|
{"dm participant without MUTE refused", Subject{RolePerms: memberBits, Channel: dm(), DMParticipant: true}, ErrPermissionDenied},
|
|
})
|
|
}
|
|
|
|
// TestSubjectHas pins the one generic predicate every other one is built on:
|
|
// Administrator bypasses overrides, everything else is the resolved two-layer
|
|
// mask, and a zero perm is never held (matching HasPerm).
|
|
func TestSubjectHas(t *testing.T) {
|
|
s := Subject{RolePerms: memberBits, Override: ChannelOverride{Deny: SendMessages, UserAllow: ManageMessages}}
|
|
if !s.Has(ReadMessages) || s.Has(SendMessages) || !s.Has(ManageMessages) || s.Has(ReadMessages|SendMessages) {
|
|
t.Fatal("Has must apply both override layers and be ALL-of")
|
|
}
|
|
if s.Has(0) {
|
|
t.Fatal("zero perm is never held")
|
|
}
|
|
if !(Subject{RolePerms: Administrator, Override: deny(AllPerms)}).Has(ManageServer) {
|
|
t.Fatal("Administrator bypasses overrides")
|
|
}
|
|
}
|