using RelayServer.Models; using SurrealDb.Net; namespace RelayServer.Services.Data; public sealed class PermissionService { private readonly SurrealDbClient _db; public PermissionService(SurrealDbClient db) { _db = db; } /// /// Owners/admins always allowed. Non-admins blocked from read-only channels (#welcome, /// #files). Everyone else passes through the normal channel-level Deny → Allow → role ladder. /// public async Task CanSendMessagesAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) return true; if (await IsChannelReadOnlyAsync(channelId)) return false; return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages); } /// Server-wide ability to create channels. Gates the "+" button on the sidebar. public async Task CanManageChannelsAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels); /// Per-channel ability to delete/edit OTHER people's messages. Authors can always delete their own. public async Task CanManageMessagesAsync(string username, string channelId) => await IsOwnerOrAdminAsync(username) || await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages); /// Convenience query — exposes the owner-or-admin shortcut as a public method. public async Task IsAdministratorAsync(string username) => await IsOwnerOrAdminAsync(username); /// /// "Visibility" — default-allow. Only blocks if a channel-level Deny mask explicitly /// removes ViewChannel for the user's role. Owners/admins bypass. /// public async Task CanViewChannelAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) return true; return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel); } /// /// Voice-channel Speak. Default-allow. Blocked by channel-level Deny. Used at RtcJoin /// time so denied users can't even register voice presence. /// public async Task CanSpeakAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) return true; return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak); } /// Server-wide ability to delete channels. ManageChannels OR explicit DeleteChannel. public async Task CanDeleteChannelAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) || await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel); /// Server-wide ability to edit channels. ManageChannels OR explicit EditChannel. public async Task CanEditChannelAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) || await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel); /// /// Step 1 of the ladder: owner flag OR Administrator permission on any assigned role. /// Owner check goes first because it doesn't require roles to be seeded — server owner /// is authoritative regardless of role-table state. /// private async Task IsOwnerOrAdminAsync(string username) { if (await IsServerOwnerAsync(username)) return true; var roles = await GetUserRolesAsync(username); return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator)); } /// /// The canonical permission ladder for per-channel checks: /// 1. Owner/admin → true. /// 2. Channel-level Deny mask for any of the user's roles → false (Deny wins). /// 3. Channel-level Allow mask for any of the user's roles → true. /// 4. Base role permissions → fallback. /// private async Task HasPermissionAsync( string username, string channelId, PermissionFlags flag) { if (await IsOwnerOrAdminAsync(username)) return true; var userRoles = await GetUserRolesAsync(username); if (userRoles.Count == 0) return false; var channelOverrides = await GetChannelPermissionsAsync(channelId); var userRoleIds = new HashSet(userRoles.Select(r => GetRecordIdString(r.Id))); foreach (var co in channelOverrides.Where(co => userRoleIds.Contains(co.RoleId))) if (co.Deny.HasFlag(flag)) return false; foreach (var co in channelOverrides.Where(co => userRoleIds.Contains(co.RoleId))) if (co.Allow.HasFlag(flag)) return true; return userRoles.Any(r => r.Permissions.HasFlag(flag)); } /// /// Server-wide (not channel-scoped) permission check. Used for things like ManageChannels /// where there's no specific channel context. Admin flag short-circuits. /// private async Task HasGlobalPermissionAsync(string username, PermissionFlags flag) { var roles = await GetUserRolesAsync(username); return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator) || r.Permissions.HasFlag(flag)); } /// /// "Was this permission explicitly denied here?" — used by default-allow permissions /// (ViewChannel, Speak) which only become restrictive when there's a Deny override. /// private async Task IsDeniedByChannelAsync(string username, string channelId, PermissionFlags flag) { var userRoles = await GetUserRolesAsync(username); if (userRoles.Count == 0) return false; var channelOverrides = await GetChannelPermissionsAsync(channelId); var userRoleIds = new HashSet(userRoles.Select(r => GetRecordIdString(r.Id))); return channelOverrides .Where(co => userRoleIds.Contains(co.RoleId)) .Any(co => co.Deny.HasFlag(flag)); } /// /// Checks ServerMembers.IsOwner directly. This is the authoritative ownership test — /// independent of the role table, so ownership keeps working even if roles aren't seeded. /// private async Task IsServerOwnerAsync(string username) { var userId = $"users:{username.ToLower()}"; var members = await _db.Select("server_members"); return members.Any(m => string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) && m.IsOwner); } /// /// Loads every Role row currently assigned to the user via UserRoles. Empty list if the /// user has no role assignments (which means they implicitly fail every permission check /// unless they happen to be the server owner). /// private async Task> GetUserRolesAsync(string username) { var userId = $"users:{username.ToLower()}"; var userRoleLinks = await _db.Select("user_roles"); var userRoleIds = userRoleLinks .Where(ur => string.Equals(ur.UserId, userId, StringComparison.OrdinalIgnoreCase)) .Select(ur => ur.RoleId) .ToHashSet(); if (userRoleIds.Count == 0) return []; var allRoles = await _db.Select("roles"); return allRoles .Where(r => userRoleIds.Contains(GetRecordIdString(r.Id))) .ToList(); } /// Loads every channel_permissions override row for a channel (all roles, all flags). private async Task> GetChannelPermissionsAsync(string channelId) { var all = await _db.Select("channel_permissions"); return all.Where(cp => cp.ChannelId == channelId).ToList(); } /// True if the channel's IsReadOnly flag is set on its row in the channels table. private async Task IsChannelReadOnlyAsync(string channelId) { var channels = await _db.Select("channels"); var channel = channels.FirstOrDefault(c => GetRecordIdString(c.Id) == channelId); return channel?.IsReadOnly ?? false; } /// SurrealDB's Id object → "table:id" string. Local copy because PermissionService isn't a friend of ChatSocketBehavior. private static string GetRecordIdString(object? id) { if (id is null) return string.Empty; var json = System.Text.Json.JsonSerializer.Serialize(id); using var doc = System.Text.Json.JsonDocument.Parse(json); var root = doc.RootElement; var recordId = root.GetProperty("Id").GetString() ?? string.Empty; var table = root.GetProperty("Table").GetString() ?? string.Empty; return $"{table}:{recordId}"; } }