mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(invariants): add the invariant-rule harness and the syncutil-locks rule * fix(ws,service): route the last five raw mutexes through syncutil The -tags deadlock CI pass only observes locks declared via syncutil, whose Mutex/RWMutex are build-tag aliases. These five were declared as raw sync types and were invisible to it, including the hub voice key-holder lock and the permission and role caches. TestServerInvariants now gates the tree against regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(invariants): walk the tree through os.Root to close a symlink TOCTOU gosec G122: reading a filepath.WalkDir-supplied path is race-prone, since a symlink swapped between the walk and the read escapes the intended tree. os.Root confines every read to the root and cannot be traversed out of. Walking the root's fs.FS also yields slash-separated paths already relative to it, so the filepath.Rel and ToSlash conversion is no longer needed. * fix(invariants): close syncutil-locks evasions, isolate per-rule tests, harden the gate - I1: TestServerInvariants now asserts every registered Rule.Scope directory exists and holds at least one non-test .go file, so the gate cannot pass by scanning nothing. - I2: split CheckSource into a thin wrapper over an unexported checkSourceWith(rules, ...), so TestSyncutilLocks tests the syncutil-locks rule in isolation instead of the whole registry. - I3: broaden checkSyncutilLocks to a single SelectorExpr match (any sync.Mutex/sync.RWMutex reference bound via f.Imports, aliases included) instead of only *ast.Field/*ast.ValueSpec. Catches := composite literals, untyped var specs, type aliases, and []sync.Mutex/map[K]sync.Mutex, none of which the old rule saw. A dot-import of "sync" is now its own violation, since it would otherwise let a bare Mutex evade the selector match entirely. - M2: suppression now keys off the violation's own Rule id (allowed[v.Line][v.Rule]) rather than the running rule's ID, so a rule that ever emits a sub-id isn't silently unsuppressible. - M3: Run sorts with sort.SliceStable, since an unreasoned allow comment and the violation it fails to suppress can share a file:line. - M4/M5/M1-partial: add a build-tag-gated fixture test, document that allow comments must be same-line, and correct the skipDirs comment to describe both the generated-code and gitignored-runtime-dir cases it actually covers. All ten original TestSyncutilLocks subtests pass unchanged; six new subtests cover the evasions above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(server): point at the syncutil-locks invariant gate Server/CLAUDE.md told developers not to hand-roll around syncutil but never said it's enforced. Note that Server/invariants/ checks it at go test time and that exceptions are greppable via grep -rn "invariant:allow" Server/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
6.7 KiB
Go
202 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/syncutil"
|
|
"github.com/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.
|
|
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 := s.getOrPopulate(ctx, userID)
|
|
if cp == nil {
|
|
return false
|
|
}
|
|
return s.checker.HasChannelPermBatch(cp.rolePerms, cp.overrides, channelID, perm)
|
|
}
|
|
|
|
// 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 := s.getOrPopulate(ctx, userID)
|
|
if cp == nil {
|
|
// Cache miss, 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 if the user's role can't be loaded.
|
|
func (s *PermissionService) getOrPopulate(ctx context.Context, userID int64) *cachedPerms {
|
|
s.mu.RLock()
|
|
cp, ok := s.cache[userID]
|
|
if ok && time.Since(cp.populatedAt) < permCacheTTL {
|
|
s.mu.RUnlock()
|
|
s.hits.Add(1)
|
|
return cp
|
|
}
|
|
startGen := s.gen
|
|
s.mu.RUnlock()
|
|
s.misses.Add(1)
|
|
|
|
// Populate.
|
|
role, err := s.st.GetRoleForUser(ctx, userID)
|
|
if err != nil || role == nil {
|
|
return 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: an empty map would silently drop every deny bit,
|
|
// and caching it would keep doing so for permCacheTTL.
|
|
slog.Error("PermissionService.getOrPopulate override fetch failed, denying", "err", oErr, "user_id", userID, "role_id", role.ID)
|
|
return nil
|
|
}
|
|
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
|
|
}
|