Files
OwnCord/Server/service/predicate_parity_test.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

167 lines
7.7 KiB
Go

package service
import (
"context"
"errors"
"testing"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// B2-5 parity tables: every service call site that decides a security
// property is run against the canonical permissions predicate over the same
// fixture, so the two can never disagree. The fixture covers each input the
// predicates consult — role bits, both override layers, channel type and
// archive flag, DM membership and blocks.
const (
parityRoleMember = int64(20) // READ|SEND
parityRoleReader = int64(21) // READ only
parityRoleMod = int64(22) // READ|SEND|MANAGE_MESSAGES
parityRoleAdmin = int64(23) // ADMINISTRATOR only
parityUserMember = int64(1)
parityUserReader = int64(2)
parityUserMod = int64(3)
parityUserAdmin = int64(4)
parityUserBob = int64(5) // member; DM partner
parityUserEve = int64(6) // member; blocked by Bob
parityChanText = int64(10)
parityChanAnnouncement = int64(11)
parityChanArchived = int64(12)
parityChanRoleDeny = int64(13) // role override denies SEND to member role
parityChanUserDeny = int64(14) // per-user override denies READ to the member user
parityChanUserAllow = int64(15) // per-user override grants MANAGE_MESSAGES to the member user; announcement
parityChanDM = int64(50) // member <-> bob
parityChanDMBlocked = int64(51) // eve <-> bob, bob blocks eve
parityChanMissing = int64(999)
)
var parityUsers = []int64{parityUserMember, parityUserReader, parityUserMod, parityUserAdmin, parityUserBob, parityUserEve}
var parityChannels = []int64{
parityChanText, parityChanAnnouncement, parityChanArchived, parityChanRoleDeny,
parityChanUserDeny, parityChanUserAllow, parityChanDM, parityChanDMBlocked, parityChanMissing,
}
func newParityDB(t *testing.T) *db.DB {
t.Helper()
database := newTestDB(t)
seedRole(t, database, &db.Role{ID: parityRoleMember, Name: "p-member", Permissions: permissions.ReadMessages | permissions.SendMessages, Position: 1})
seedRole(t, database, &db.Role{ID: parityRoleReader, Name: "p-reader", Permissions: permissions.ReadMessages, Position: 1})
seedRole(t, database, &db.Role{ID: parityRoleMod, Name: "p-mod", Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, Position: 2})
seedRole(t, database, &db.Role{ID: parityRoleAdmin, Name: "p-admin", Permissions: permissions.Administrator, Position: 3})
for uid, rid := range map[int64]int64{
parityUserMember: parityRoleMember, parityUserReader: parityRoleReader, parityUserMod: parityRoleMod,
parityUserAdmin: parityRoleAdmin, parityUserBob: parityRoleMember, parityUserEve: parityRoleMember,
} {
seedUser(t, database, &db.User{ID: uid, Username: seedUsername(uid)})
seedUserRole(t, database, uid, rid)
}
seedChannel(t, database, &db.Channel{ID: parityChanText, Name: "text", Type: "text"})
seedChannel(t, database, &db.Channel{ID: parityChanAnnouncement, Name: "news", Type: "announcement"})
seedChannel(t, database, &db.Channel{ID: parityChanArchived, Name: "old", Type: "text"})
if _, err := database.ExecContext(context.Background(), `UPDATE channels SET archived = 1 WHERE id = ?`, parityChanArchived); err != nil {
t.Fatalf("archive: %v", err)
}
seedChannel(t, database, &db.Channel{ID: parityChanRoleDeny, Name: "role-deny", Type: "text"})
seedChannelOverride(t, database, parityRoleMember, parityChanRoleDeny, 0, permissions.SendMessages)
seedChannel(t, database, &db.Channel{ID: parityChanUserDeny, Name: "user-deny", Type: "text"})
seedChannelUserOverride(t, database, parityUserMember, parityChanUserDeny, 0, permissions.ReadMessages)
seedChannel(t, database, &db.Channel{ID: parityChanUserAllow, Name: "user-allow", Type: "announcement"})
seedChannelUserOverride(t, database, parityUserMember, parityChanUserAllow, permissions.ManageMessages, 0)
seedChannel(t, database, &db.Channel{ID: parityChanDM, Name: "dm", Type: "dm"})
seedDMParticipant(t, database, parityChanDM, parityUserMember)
seedDMParticipant(t, database, parityChanDM, parityUserBob)
seedChannel(t, database, &db.Channel{ID: parityChanDMBlocked, Name: "dm-blocked", Type: "dm"})
seedDMParticipant(t, database, parityChanDMBlocked, parityUserEve)
seedDMParticipant(t, database, parityChanDMBlocked, parityUserBob)
seedBlock(t, database, parityUserBob, parityUserEve)
return database
}
// parityWant is the canonical verdict for (user, channel) from the predicate
// over a Subject the test resolves itself — independently of the call site.
func parityWant(t *testing.T, database *db.DB, perms *PermissionService, pred func(permissions.Subject) error, userID, channelID int64) (allowed bool, verdict error) {
t.Helper()
ch, err := database.GetChannel(context.Background(), channelID)
if err != nil {
t.Fatalf("GetChannel(%d): %v", channelID, err)
}
if ch == nil {
return false, ErrNotFound
}
sub, err := channelSubject(context.Background(), database, perms, userID, ch, true)
if err != nil {
t.Fatalf("channelSubject(%d,%d): %v", userID, channelID, err)
}
verdict = pred(sub)
return verdict == nil, verdict
}
// TestSendPolicyParity: CanPost (the send path) and HandleTyping (S-01) agree
// with CanSendMessage for every (user, channel) in the fixture, including the
// kind of refusal.
func TestSendPolicyParity(t *testing.T) {
database := newParityDB(t)
perms := NewPermissionService(database, permissions.NewChecker(database))
msgSvc := NewMessageService(database, perms, nil)
chSvc := NewChannelService(database, perms)
ctx := context.Background()
for _, uid := range parityUsers {
for _, cid := range parityChannels {
wantOK, want := parityWant(t, database, perms, permissions.CanSendMessage, uid, cid)
got := msgSvc.CanPost(ctx, uid, cid)
if (got == nil) != wantOK {
t.Errorf("CanPost(user=%d, chan=%d) = %v, predicate says %v", uid, cid, got, want)
}
switch {
case errors.Is(want, permissions.ErrBlocked) && !errors.Is(got, ErrBlocked):
t.Errorf("CanPost(user=%d, chan=%d) = %v, want ErrBlocked", uid, cid, got)
case errors.Is(want, ErrNotFound) && !errors.Is(got, ErrNotFound):
t.Errorf("CanPost(user=%d, chan=%d) = %v, want ErrNotFound", uid, cid, got)
case want != nil && !errors.Is(want, permissions.ErrBlocked) && !errors.Is(want, ErrNotFound) && !errors.Is(got, ErrForbidden):
t.Errorf("CanPost(user=%d, chan=%d) = %v, want ErrForbidden", uid, cid, got)
}
ch, err := chSvc.HandleTyping(ctx, uid, cid, nil)
if err != nil {
t.Errorf("HandleTyping(user=%d, chan=%d) errored: %v", uid, cid, err)
}
if (ch != nil) != wantOK {
t.Errorf("HandleTyping(user=%d, chan=%d) emits=%v, but CanSendMessage says %v (S-01: typing must follow the send policy)", uid, cid, ch != nil, want)
}
}
}
}
// TestViewPolicyParity: HandleChannelFocus (session admission: channel_focus
// and mark_read) agrees with CanAdmitSession for every (user, channel).
func TestViewPolicyParity(t *testing.T) {
database := newParityDB(t)
perms := NewPermissionService(database, permissions.NewChecker(database))
chSvc := NewChannelService(database, perms)
ctx := context.Background()
for _, uid := range parityUsers {
for _, cid := range parityChannels {
wantOK, want := parityWant(t, database, perms, permissions.CanAdmitSession, uid, cid)
_, got := chSvc.HandleChannelFocus(ctx, uid, cid)
if (got == nil) != wantOK {
t.Errorf("HandleChannelFocus(user=%d, chan=%d) = %v, predicate says %v", uid, cid, got, want)
}
switch {
case errors.Is(want, ErrNotFound) && !errors.Is(got, ErrNotFound):
t.Errorf("HandleChannelFocus(user=%d, chan=%d) = %v, want ErrNotFound", uid, cid, got)
case want != nil && !errors.Is(want, ErrNotFound) && !errors.Is(got, ErrForbidden):
t.Errorf("HandleChannelFocus(user=%d, chan=%d) = %v, want ErrForbidden", uid, cid, got)
}
}
}
}