Files
OwnCord/Server/service/permission.go
T
Claude a2cb224323 feat: scaffold Phase B + C (events, telemetry, plugins, Solid.js)
Phase B Step 6 — Solid.js incremental migration
  - vite-plugin-solid + solid-js + @solidjs/testing-library in package.json
  - vite.config.ts compiles src/components/solid/** as Solid TSX
  - tsconfig.json gains jsx: preserve / jsxImportSource: solid-js
  - lib/solidAdapter.ts wraps existing custom Stores as Solid signals
  - lib/solidMount.ts adapts Solid render to {mount,destroy} contract
  - components/solid/Badge.tsx (proof-of-concept leaf)
  - components/solid/ChannelListItem.tsx (store-subscribed leaf)
  - components/solid/Badge.test.tsx pipeline smoke test
  - components/solid/README.md documents the migration recipe

Phase B Step 7 — Event persistence layer
  - SQLite + Postgres migrations for the events table
  - sqlc query files for both engines
  - EventStore interface + SQLite raw-SQL impl + MemStore impl + pg stubs
  - ws.EventPersister: async batched writer (queue / flush / drain / drop)
  - ws.StartEventPruner: background retention pruner
  - hub persists every replay-buffer push and exposes reconnect-tier counters
  - serve.handleReconnect: tiered replay (buffer -> DB -> full re-sync)
  - EventPersistenceConfig + main.go wiring
  - event_persister_test.go covers batching / drops / drain

Phase B Step 8 — OpenTelemetry skeleton
  - Server/telemetry package with public Provider/Tracer/Meter/Counter API
  - telemetry_default.go (no-op build) + telemetry_otel.go (build tag otel)
  - telemetry/metrics.go declares the AppMetrics bundle
  - HTTPMiddleware mounted in Chi router (pass-through in default build)
  - PrometheusHandler optionally mounted at /metrics
  - Spans on MessageService.SendMessage, PermissionService.HasChannelPerm,
    ChannelService.ListVisibleChannels
  - Reconnect-tier counter wired into the global meter
  - TelemetryConfig defaults

Phase C Step 9 — Wazero plugin runtime skeleton
  - Server/plugin package: manifest parser, loader, registry, host APIs
    (commands, storage, events, http, ui), errors
  - sandbox_default.go (no-op) + sandbox_wazero.go (build tag wazero)
  - SQLite + Postgres migrations for plugins + plugin_kv tables
  - PluginStore interface + impls + pg stubs
  - plugin/examples/hello manifest + README
  - plugin_test.go covers manifest, loader, capability gating
  - api/plugins_handler.go admin REST surface, mounted under admin group
  - PluginsConfig + main.go wiring (disabled by default)
  - Client: lib/pluginBridge.ts iframe + postMessage host
  - Client: components/solid/PluginContainer.tsx Solid host component

Verification
  - Default build (no -tags) is intended to compile cleanly with no new
    third-party dependencies. The sandbox lacked Go 1.25.0 so go build
    could not run; PHASE_BC_LOCAL_TODO.md enumerates the local follow-up
    work (npm install, go mod tidy, sqlc-generate, real otel/wazero
    wiring, remaining service spans, full Solid migration).
2026-04-06 09:00:47 +00:00

164 lines
4.8 KiB
Go

package service
import (
"context"
"sync"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/store"
"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]db.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.Store
checker *permissions.Checker
mu sync.RWMutex
cache map[int64]*cachedPerms // keyed by userID
}
// NewPermissionService creates a PermissionService backed by the given DB.
func NewPermissionService(st store.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.
func (s *PermissionService) HasChannelPerm(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.
_, span := telemetry.GlobalTracer("service/permission").Start(context.Background(),
"PermissionService.HasChannelPerm",
telemetry.Int64("user_id", userID),
telemetry.Int64("channel_id", channelID),
)
defer span.End()
cp := s.getOrPopulate(userID)
if cp == nil {
return false
}
if permissions.HasAdmin(cp.rolePerms) {
return true
}
o := cp.overrides[channelID] // zero-value (0,0) when no override exists
effective := permissions.EffectivePerms(cp.rolePerms, o.Allow, o.Deny)
return effective&perm == 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(userID int64, channelType string, channelID, perm int64) error {
if channelType == "dm" {
ok, err := s.st.IsDMParticipant(userID, channelID)
if err != nil {
return err
}
if !ok {
return permissions.ErrNotDMParticipant
}
return nil
}
if !s.HasChannelPerm(userID, channelID, perm) {
return permissions.ErrPermissionDenied
}
return nil
}
// GetRoleForUser returns the user's role, using the cache when available.
func (s *PermissionService) GetRoleForUser(userID int64) (*db.Role, error) {
cp := s.getOrPopulate(userID)
if cp == nil {
// Cache miss, fall back to direct DB query.
return s.st.GetRoleForUser(userID)
}
return s.st.GetRoleByID(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.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.mu.Unlock()
}
// InvalidateAll clears the entire permission cache.
func (s *PermissionService) InvalidateAll() {
s.mu.Lock()
s.cache = make(map[int64]*cachedPerms)
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
}
// 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(userID int64) *cachedPerms {
s.mu.RLock()
cp, ok := s.cache[userID]
if ok && time.Since(cp.populatedAt) < permCacheTTL {
s.mu.RUnlock()
return cp
}
s.mu.RUnlock()
// Populate.
role, err := s.st.GetRoleForUser(userID)
if err != nil || role == nil {
return nil
}
overrides, err := s.st.GetAllChannelPermissionsForRole(role.ID)
if err != nil {
// Fall back to uncached if override fetch fails.
overrides = make(map[int64]db.ChannelOverride)
}
cp = &cachedPerms{
roleID: role.ID,
rolePerms: role.Permissions,
overrides: overrides,
populatedAt: time.Now(),
}
s.mu.Lock()
s.cache[userID] = cp
s.mu.Unlock()
return cp
}