Files
OwnCord/Server/service/permission_test.go
T
J3vbandClaude Fable 5 a4eca1a55a fix(perms): own the server-scoped rule in HasServerPerm and fail closed on override-fetch errors (D13)
Closes audit finding A-2026-07-16, two defects in the same rule:

- permissions.HasServerPerm (admin bypass OR all-of bit test) replaces
  the hand-rolled copies in api.RequirePermission (whose raw test was
  any-of for multi-bit masks) and ModerationService.requireBanPermission.
  RequirePermission's doc comment now states the scope contract: role
  bitfield only, channel overrides deliberately not consulted.
- PermissionService.getOrPopulate and ChannelService.ListVisibleChannels
  no longer substitute an empty override map when
  GetAllChannelPermissionsForRole errors. That silently dropped every
  channel-level deny — and the permission cache then served the degraded
  snapshot for permCacheTTL (30s) across ~25 callers. Both fail closed
  now; admins skip the fetch entirely (they bypass channel checks).
- PermissionService.HasChannelPerm delegates to Checker.HasChannelPermBatch
  and MessageService.GetAccessibleChannelIDs to VisibleChannelIDs — the
  missed fifth D9 site, making that closure true rather than aspirational.
- AuthMiddleware rejects a dangling role_id (GetRoleByID returns nil,
  nil) with 401 instead of putting a nil role in the request context.

Locked by failing-first tests: override-fetch-error denies (cached and
uncached paths), admin-outage skip, multi-bit all-of, channel allow
override must not grant a server-wide route, 403 locks on both
RequirePermission routes, dangling-role 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:45:34 +02:00

323 lines
12 KiB
Go

package service
import (
"errors"
"testing"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// errOverrideStore wraps a real *db.DB but always fails the channel-override
// fetch, so the fail-closed contract (A-2026-07-16) is testable. Embedding
// *db.DB satisfies the service Store interface; only the one overridden method
// diverges, every other call still hits the real database.
type errOverrideStore struct {
*db.DB
}
func (errOverrideStore) GetAllChannelPermissionsForRole(int64) (map[int64]db.ChannelOverride, error) {
return nil, errors.New("boom")
}
// TestHasChannelPerm_OverrideFetchErrorDenies locks the fail-closed rule: when
// the override fetch errors we must NOT substitute an empty map, because that
// restores every bit a channel-level deny had stripped — and PermissionService
// would then cache that degraded snapshot for permCacheTTL.
func TestHasChannelPerm_OverrideFetchErrorDenies(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
Position: 1,
})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages)
svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database))
if svc.HasChannelPerm(1, 10, permissions.ReadMessages) {
t.Fatal("override fetch failure must deny, not fall back to the base role bits")
}
}
// newTestPermService creates a PermissionService backed by a real in-memory DB
// pre-populated with a single role and user.
func newTestPermService(t *testing.T) (*PermissionService, *db.DB) {
t.Helper()
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
Position: 1,
})
seedUserRole(t, database, 1, permissions.MemberRoleID)
checker := permissions.NewChecker(database)
return NewPermissionService(database, checker), database
}
func TestHasChannelPerm_Allowed(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected user to have SendMessages permission")
}
if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) {
t.Fatal("expected user to have ReadMessages permission")
}
}
func TestHasChannelPerm_Denied(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// ManageMessages is NOT in the member role.
if svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
t.Fatal("expected user to NOT have ManageMessages permission")
}
}
func TestHasChannelPerm_OverrideDeny(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
// Deny SendMessages for this channel.
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Invalidate so next check re-populates cache.
svc.InvalidateAll()
if svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected SendMessages to be denied via channel override")
}
// ReadMessages should still be allowed.
if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) {
t.Fatal("expected ReadMessages to remain allowed")
}
}
func TestHasChannelPerm_OverrideAllow(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "special", Type: "text"})
// Allow ManageMessages (not in base role) via channel override.
seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0)
svc.InvalidateAll()
if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
t.Fatal("expected ManageMessages to be allowed via channel override")
}
}
func TestHasChannelPerm_AdminBypass(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.AdminRoleID,
Name: "admin",
Permissions: permissions.Administrator,
Position: 90,
})
seedUserRole(t, database, 1, permissions.AdminRoleID)
checker := permissions.NewChecker(database)
svc := NewPermissionService(database, checker)
seedChannel(t, database, &db.Channel{ID: 10, Name: "locked", Type: "text"})
// Deny everything via override; admin should still bypass.
seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages)
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("admin should bypass all permission checks")
}
if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
t.Fatal("admin should bypass all permission checks")
}
}
// TestHasChannelPerm_AdminSkipsOverrideFetch locks the admin skip in
// getOrPopulate: fail-closed must not extend to admins, who bypass every
// channel check anyway. Without the skip, an override-fetch outage would
// deny admins everything instead of nothing.
func TestHasChannelPerm_AdminSkipsOverrideFetch(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.AdminRoleID,
Name: "admin",
Permissions: permissions.Administrator,
Position: 90,
})
seedUserRole(t, database, 1, permissions.AdminRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database))
if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
t.Fatal("admin must not be denied by an override-fetch outage; the fetch is skipped for admins")
}
}
func TestInvalidateUser_ClearsCacheForUser(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
// Now add a deny override.
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Without invalidation, cache still says allowed.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected cached value to still allow SendMessages")
}
// After invalidation, should pick up the override.
svc.InvalidateUser(1)
if svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected SendMessages to be denied after cache invalidation")
}
}
func TestInvalidateAll_ClearsEntireCache(t *testing.T) {
svc, database := newTestPermService(t)
// Add a second user.
seedUserRole(t, database, 2, permissions.MemberRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache for both users.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
svc.HasChannelPerm(2, 10, permissions.SendMessages)
// Add deny override.
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Both still cached as allowed.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected cached allow for user 1")
}
if !svc.HasChannelPerm(2, 10, permissions.SendMessages) {
t.Fatal("expected cached allow for user 2")
}
svc.InvalidateAll()
// Both should now see the deny.
if svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected deny for user 1 after InvalidateAll")
}
if svc.HasChannelPerm(2, 10, permissions.SendMessages) {
t.Fatal("expected deny for user 2 after InvalidateAll")
}
}
func TestPermCacheTTLExpiry(t *testing.T) {
// This test verifies the cache TTL mechanism. We cannot easily wait 30s
// in a unit test, so we verify the structural behavior: after manually
// backdating the populatedAt field the cache should be stale and the
// next check should re-populate from the store.
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
// Add deny override.
seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Manually expire the cache entry by backdating populatedAt.
svc.mu.Lock()
if cp, ok := svc.cache[int64(1)]; ok {
cp.populatedAt = time.Now().Add(-permCacheTTL - time.Second)
}
svc.mu.Unlock()
// The next call should re-populate and pick up the deny.
if svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("expected cache TTL expiry to cause re-population with deny override")
}
}
func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) {
svc, database := newTestPermService(t)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// User 999 has no role assigned.
if svc.HasChannelPerm(999, 10, permissions.SendMessages) {
t.Fatal("expected false for unknown user")
}
}
// raceHookStore wraps a real *db.DB and fires a hook right after the role read
// inside getOrPopulate, letting a test deterministically inject a concurrent
// invalidation into the populate's read→store window.
type raceHookStore struct {
*db.DB
onGetRole func()
}
func (s *raceHookStore) GetRoleForUser(userID int64) (*db.Role, error) {
r, err := s.DB.GetRoleForUser(userID)
if s.onGetRole != nil {
s.onGetRole()
}
return r, err
}
// TestGetOrPopulate_InvalidationDuringPopulateNotLost locks F6: an invalidation
// that races a populate (landing after the DB read but before the cache store)
// must not be silently overwritten by the stale snapshot. Otherwise a just-revoked
// permission keeps being served for up to permCacheTTL (30s). All three
// invalidation entry points bump the generation, so each is locked separately.
func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) {
cases := []struct {
name string
invalidate func(*PermissionService)
}{
{"InvalidateUser", func(s *PermissionService) { s.InvalidateUser(1) }},
{"InvalidateChannel", func(s *PermissionService) { s.InvalidateChannel(10) }},
{"InvalidateAll", func(s *PermissionService) { s.InvalidateAll() }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
database := newTestDB(t)
seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages,
Position: 1,
})
seedUserRole(t, database, 1, permissions.MemberRoleID)
seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
store := &raceHookStore{DB: database}
svc := NewPermissionService(store, permissions.NewChecker(database))
fired := false
store.onGetRole = func() {
if fired {
return
}
fired = true
// Admin demotes the role (removes SendMessages) and invalidates,
// racing this populate between its role read and its cache store.
if _, err := database.Exec(`UPDATE roles SET permissions = ? WHERE id = ?`,
permissions.ReadMessages, permissions.MemberRoleID); err != nil {
t.Errorf("demote role: %v", err)
}
tc.invalidate(svc)
}
// This populate reads the pre-demotion perms; the racing invalidation
// must stop that stale snapshot from being cached.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
// A fresh check must re-read the DB and see the revoked permission.
if svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("revoked SendMessages served from a stale snapshot; a populate that races an invalidation must not be cached")
}
})
}
}