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}";
}
}