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>
255 lines
9.4 KiB
Go
255 lines
9.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/OwnCord/Server/syncutil"
|
|
"github.com/J3vb/OwnCord/Server/telemetry"
|
|
)
|
|
|
|
// cachedPerms holds a snapshot of a user's role and channel overrides.
|
|
type cachedPerms struct {
|
|
roleID int64
|
|
rolePerms int64
|
|
overrides map[int64]permissions.ChannelOverride
|
|
populatedAt time.Time
|
|
}
|
|
|
|
// permCacheTTL is how long cached permissions remain valid before refresh.
|
|
const permCacheTTL = 30 * time.Second
|
|
|
|
// PermissionService wraps the stateless permissions.Checker with per-user
|
|
// caching. It eliminates per-message DB round-trips for permission checks
|
|
// at scale. The cache is populated lazily on first access and invalidated
|
|
// on role or channel override changes.
|
|
type PermissionService struct {
|
|
st Store
|
|
checker *permissions.Checker
|
|
|
|
mu syncutil.RWMutex
|
|
cache map[int64]*cachedPerms // keyed by userID
|
|
// gen is bumped by every Invalidate* call. getOrPopulate snapshots it before
|
|
// its DB read and refuses to cache if it changed, so an invalidation that
|
|
// races a populate can't be lost (F6).
|
|
gen uint64
|
|
|
|
// hits/misses are atomics, not mu-guarded ints: the hit path holds mu only
|
|
// as an RLock, so a plain increment there would race.
|
|
hits atomic.Uint64
|
|
misses atomic.Uint64
|
|
}
|
|
|
|
// NewPermissionService creates a PermissionService backed by the given DB.
|
|
func NewPermissionService(st Store, checker *permissions.Checker) *PermissionService {
|
|
return &PermissionService{
|
|
st: st,
|
|
checker: checker,
|
|
cache: make(map[int64]*cachedPerms),
|
|
}
|
|
}
|
|
|
|
// HasChannelPerm reports whether the user has the required permission bits
|
|
// on the given channel. Uses cached role/override data when available.
|
|
// Cancellation of ctx reaches the underlying store reads. Any lookup failure
|
|
// (a transient DB hiccup as much as a genuine "no role") collapses to false —
|
|
// callers that need to tell those apart use HasChannelPermChecked instead.
|
|
func (s *PermissionService) HasChannelPerm(ctx context.Context, userID, channelID, perm int64) bool {
|
|
// Phase B Step 8 — span the perm check so traces show how many permission
|
|
// lookups a single REST/WS request triggers. The cache hit path is fast,
|
|
// but knowing how often it misses is the whole point of having metrics.
|
|
ctx, span := telemetry.GlobalTracer("service/permission").Start(ctx,
|
|
"PermissionService.HasChannelPerm",
|
|
telemetry.Int64("user_id", userID),
|
|
telemetry.Int64("channel_id", channelID),
|
|
)
|
|
defer span.End()
|
|
cp, err := s.getOrPopulate(ctx, userID)
|
|
if err != nil || cp == nil {
|
|
return false
|
|
}
|
|
return s.checker.HasChannelPermBatch(cp.rolePerms, cp.overrides, channelID, perm)
|
|
}
|
|
|
|
// HasChannelPermChecked is HasChannelPerm's error-preserving counterpart: it
|
|
// distinguishes "the store lookup failed" (err != nil, verdict meaningless)
|
|
// from "the store answered and the user lacks the bit" (false, nil error).
|
|
// OC-0266: a caller like ws's applySetChannelID post-Subscribe revalidation
|
|
// must not treat a transient DB hiccup on the role/override read as a
|
|
// positive denial — that already-fail-closed collapse belongs to
|
|
// HasChannelPerm and the rest of this service's callers, not to a caller that
|
|
// documents "a transient lookup error is NOT a denial".
|
|
func (s *PermissionService) HasChannelPermChecked(ctx context.Context, userID, channelID, perm int64) (bool, error) {
|
|
cp, err := s.getOrPopulate(ctx, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if cp == nil {
|
|
// No role row: a genuine "no permissions" outcome, not a lookup
|
|
// failure — HasChannelPerm has always treated it as a plain false.
|
|
return false, nil
|
|
}
|
|
return s.checker.HasChannelPermBatch(cp.rolePerms, cp.overrides, channelID, perm), nil
|
|
}
|
|
|
|
// Subject resolves the user's role bits and both override layers for
|
|
// channelID from the per-user cache, as a permissions.Subject for the
|
|
// value-taking predicates (CanSendMessage and friends). Channel flags and DM
|
|
// state are the caller's to fill in. A missing role row yields the zero
|
|
// Subject (no bits — every predicate refuses it) with a nil error; a store
|
|
// failure is returned so callers choose between failing closed and
|
|
// reporting it.
|
|
func (s *PermissionService) Subject(ctx context.Context, userID, channelID int64) (permissions.Subject, error) {
|
|
cp, err := s.getOrPopulate(ctx, userID)
|
|
if err != nil {
|
|
return permissions.Subject{}, err
|
|
}
|
|
if cp == nil {
|
|
return permissions.Subject{}, nil
|
|
}
|
|
return permissions.Subject{RolePerms: cp.rolePerms, Override: cp.overrides[channelID]}, nil
|
|
}
|
|
|
|
// RequireChannelAccess checks whether the user can access the channel with
|
|
// the given permission. For DM channels it verifies participant membership.
|
|
// For regular channels it uses cached role-based permission checks.
|
|
func (s *PermissionService) RequireChannelAccess(ctx context.Context, userID int64, channelType string, channelID, perm int64) error {
|
|
if channelType == "dm" {
|
|
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return permissions.ErrNotDMParticipant
|
|
}
|
|
return nil
|
|
}
|
|
if !s.HasChannelPerm(ctx, userID, channelID, perm) {
|
|
return permissions.ErrPermissionDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetRoleForUser returns the user's role, using the cache when available.
|
|
func (s *PermissionService) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) {
|
|
cp, err := s.getOrPopulate(ctx, userID)
|
|
if err != nil || cp == nil {
|
|
// Cache miss (or a lookup failure the cache couldn't populate through),
|
|
// fall back to direct DB query.
|
|
return s.st.GetRoleForUser(ctx, userID)
|
|
}
|
|
return s.st.GetRoleByID(ctx, cp.roleID)
|
|
}
|
|
|
|
// InvalidateUser removes cached permissions for a specific user.
|
|
// Call this when a user's role changes.
|
|
func (s *PermissionService) InvalidateUser(userID int64) {
|
|
s.mu.Lock()
|
|
delete(s.cache, userID)
|
|
s.gen++
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// InvalidateChannel removes cached permissions for ALL users, since a
|
|
// channel override change can affect any user with that role.
|
|
// Call this when channel_overrides are modified.
|
|
func (s *PermissionService) InvalidateChannel(_ int64) {
|
|
s.mu.Lock()
|
|
s.cache = make(map[int64]*cachedPerms)
|
|
s.gen++
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// InvalidateAll clears the entire permission cache.
|
|
func (s *PermissionService) InvalidateAll() {
|
|
s.mu.Lock()
|
|
s.cache = make(map[int64]*cachedPerms)
|
|
s.gen++
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Checker returns the underlying stateless permissions.Checker for cases
|
|
// where callers need direct access (e.g., batch channel filtering).
|
|
func (s *PermissionService) Checker() *permissions.Checker {
|
|
return s.checker
|
|
}
|
|
|
|
// CacheStats returns the lifetime hit/miss counters of the permission cache.
|
|
// A miss is any lookup that had to repopulate from the store — including
|
|
// TTL-expired entries and post-invalidation lookups — so a burst of misses
|
|
// right after a role or override change is the cache-wide invalidation cost
|
|
// showing up, not a bug. Safe to call from any goroutine.
|
|
func (s *PermissionService) CacheStats() (hits, misses uint64) {
|
|
return s.hits.Load(), s.misses.Load()
|
|
}
|
|
|
|
// getOrPopulate returns cached perms for the user, populating the cache on
|
|
// miss or staleness. Returns (nil, nil) if the user genuinely has no role row
|
|
// (not a lookup failure — there is nothing to cache and nothing to retry).
|
|
// Returns (nil, err) if a store read failed, so callers that must not
|
|
// collapse a transient DB hiccup into a denial (HasChannelPermChecked) can
|
|
// tell it apart from that legitimate "no role" case; HasChannelPerm and
|
|
// GetRoleForUser keep their existing fail-closed behavior by treating either
|
|
// case the same way.
|
|
func (s *PermissionService) getOrPopulate(ctx context.Context, userID int64) (*cachedPerms, error) {
|
|
s.mu.RLock()
|
|
cp, ok := s.cache[userID]
|
|
if ok && time.Since(cp.populatedAt) < permCacheTTL {
|
|
s.mu.RUnlock()
|
|
s.hits.Add(1)
|
|
return cp, nil
|
|
}
|
|
startGen := s.gen
|
|
s.mu.RUnlock()
|
|
s.misses.Add(1)
|
|
|
|
// Populate.
|
|
role, err := s.st.GetRoleForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if role == nil {
|
|
return nil, nil
|
|
}
|
|
// Admins bypass every channel check, so skip the fetch entirely (mirrors
|
|
// ChannelService.ListVisibleChannels and ws.buildReady). The fetch pulls
|
|
// BOTH override layers (role + per-user) in two batch queries, so the
|
|
// cached snapshot can answer the full Discord resolution order without an
|
|
// extra query per channel.
|
|
var overrides map[int64]permissions.ChannelOverride
|
|
if !permissions.HasAdmin(role.Permissions) {
|
|
raw, oErr := s.st.GetChannelOverridesFor(ctx, role.ID, userID)
|
|
if oErr != nil {
|
|
// Fail closed (for HasChannelPerm/GetRoleForUser): an empty map
|
|
// would silently drop every deny bit, and caching it would keep
|
|
// doing so for permCacheTTL. HasChannelPermChecked callers get the
|
|
// error itself and decide for themselves whether that's a denial.
|
|
slog.Error("PermissionService.getOrPopulate override fetch failed, denying", "err", oErr, "user_id", userID, "role_id", role.ID)
|
|
return nil, oErr
|
|
}
|
|
overrides = permOverrides(raw)
|
|
}
|
|
|
|
cp = &cachedPerms{
|
|
roleID: role.ID,
|
|
rolePerms: role.Permissions,
|
|
overrides: overrides,
|
|
populatedAt: time.Now(),
|
|
}
|
|
|
|
s.mu.Lock()
|
|
// F6: only cache if no invalidation raced our DB read. If gen moved, an
|
|
// InvalidateUser/InvalidateChannel/InvalidateAll landed after we snapshotted
|
|
// it, so this snapshot may already be stale — return it for this one request
|
|
// but don't poison the cache with it for permCacheTTL.
|
|
if s.gen == startGen {
|
|
s.cache[userID] = cp
|
|
}
|
|
s.mu.Unlock()
|
|
return cp, nil
|
|
}
|