💥 Simplified permission node system and data structure

This commit is contained in:
2025-12-02 21:42:26 +08:00
parent fa2f53ff7a
commit 158cc75c5b
32 changed files with 3333 additions and 379 deletions

View File

@@ -69,7 +69,7 @@ public class DeveloperController(
[HttpPost("{name}/enroll")] [HttpPost("{name}/enroll")]
[Authorize] [Authorize]
[RequiredPermission("global", "developers.create")] [AskPermission("developers.create")]
public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name) public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();

View File

@@ -16,7 +16,7 @@ public static class ApplicationConfiguration
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<RemotePermissionMiddleware>();
app.MapControllers(); app.MapControllers();

View File

@@ -381,7 +381,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpDelete("recycle")] [HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")] [AskPermission("files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles() public async Task<ActionResult> DeleteAllRecycledFiles()
{ {
var count = await fs.DeleteAllRecycledFilesAsync(); var count = await fs.DeleteAllRecycledFilesAsync();

View File

@@ -113,7 +113,7 @@ public class FileUploadController(
if (currentUser.IsSuperuser) return null; if (currentUser.IsSuperuser) return null;
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); { Actor = currentUser.Id, Key = "files.create" });
return allowed.HasPermission return allowed.HasPermission
? null ? null

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
@@ -319,7 +320,7 @@ public class AccountController(
[HttpPost("credits/validate")] [HttpPost("credits/validate")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "credits.validate.perform")] [AskPermission("credits.validate.perform")]
public async Task<IActionResult> PerformSocialCreditValidation() public async Task<IActionResult> PerformSocialCreditValidation()
{ {
await socialCreditService.ValidateSocialCredits(); await socialCreditService.ValidateSocialCredits();
@@ -328,7 +329,7 @@ public class AccountController(
[HttpDelete("{name}")] [HttpDelete("{name}")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "accounts.deletion")] [AskPermission("accounts.deletion")]
public async Task<IActionResult> AdminDeleteAccount(string name) public async Task<IActionResult> AdminDeleteAccount(string name)
{ {
var account = await accounts.LookupAccount(name); var account = await accounts.LookupAccount(name);

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -194,7 +195,7 @@ public class AccountCurrentController(
} }
[HttpPatch("statuses")] [HttpPatch("statuses")]
[RequiredPermission("global", "accounts.statuses.update")] [AskPermission("accounts.statuses.update")]
public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
@@ -228,7 +229,7 @@ public class AccountCurrentController(
} }
[HttpPost("statuses")] [HttpPost("statuses")]
[RequiredPermission("global", "accounts.statuses.create")] [AskPermission("accounts.statuses.create")]
public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();

View File

@@ -158,7 +158,7 @@ public class AccountService(
{ {
db.PermissionGroupMembers.Add(new SnPermissionGroupMember db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{ {
Actor = $"user:{account.Id}", Actor = account.Id.ToString(),
Group = defaultGroup Group = defaultGroup
}); });
} }

View File

@@ -194,7 +194,7 @@ public class MagicSpellService(
{ {
db.PermissionGroupMembers.Add(new SnPermissionGroupMember db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{ {
Actor = $"user:{account.Id}", Actor = account.Id.ToString(),
Group = defaultGroup Group = defaultGroup
}); });
} }

View File

@@ -103,7 +103,7 @@ public class AppDatabase(
"stickers.packs.create", "stickers.packs.create",
"stickers.create" "stickers.create"
}.Select(permission => }.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true)) PermissionService.NewPermissionNode("group:default", permission, true))
.ToList() .ToList()
}); });
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -81,7 +82,7 @@ public class LotteryController(AppDatabase db, LotteryService lotteryService) :
[HttpPost("draw")] [HttpPost("draw")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "lotteries.draw.perform")] [AskPermission("lotteries.draw.perform")]
public async Task<IActionResult> PerformLotteryDraw() public async Task<IActionResult> PerformLotteryDraw()
{ {
await lotteryService.DrawLotteries(); await lotteryService.DrawLotteries();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class SimplifiedPermissionNode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_permission_nodes_key_area_actor",
table: "permission_nodes");
migrationBuilder.DropColumn(
name: "area",
table: "permission_nodes");
migrationBuilder.AddColumn<int>(
name: "type",
table: "permission_nodes",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_actor",
table: "permission_nodes",
columns: new[] { "key", "actor" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_permission_nodes_key_actor",
table: "permission_nodes");
migrationBuilder.DropColumn(
name: "type",
table: "permission_nodes");
migrationBuilder.AddColumn<string>(
name: "area",
table: "permission_nodes",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_area_actor",
table: "permission_nodes",
columns: new[] { "key", "area", "actor" });
}
}
}

View File

@@ -1,17 +1,12 @@
using DysonNetwork.Shared.Auth;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
using System; using System;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using DysonNetwork.Shared.Models; using Shared.Models;
[AttributeUsage(AttributeTargets.Method)] public class LocalPermissionMiddleware(RequestDelegate next, ILogger<LocalPermissionMiddleware> logger)
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddleware> logger)
{ {
private const string ForbiddenMessage = "Insufficient permissions"; private const string ForbiddenMessage = "Insufficient permissions";
private const string UnauthorizedMessage = "Authentication required"; private const string UnauthorizedMessage = "Authentication required";
@@ -21,15 +16,15 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
var endpoint = httpContext.GetEndpoint(); var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>() .OfType<AskPermissionAttribute>()
.FirstOrDefault(); .FirstOrDefault();
if (attr != null) if (attr != null)
{ {
// Validate permission attributes // Validate permission attributes
if (string.IsNullOrWhiteSpace(attr.Area) || string.IsNullOrWhiteSpace(attr.Key)) if (string.IsNullOrWhiteSpace(attr.Key))
{ {
logger.LogWarning("Invalid permission attribute: Area='{Area}', Key='{Key}'", attr.Area, attr.Key); logger.LogWarning("Invalid permission attribute: Key='{Key}'", attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Server configuration error"); await httpContext.Response.WriteAsync("Server configuration error");
return; return;
@@ -37,7 +32,7 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
if (httpContext.Items["CurrentUser"] is not SnAccount currentUser) if (httpContext.Items["CurrentUser"] is not SnAccount currentUser)
{ {
logger.LogWarning("Permission check failed: No authenticated user for {Area}/{Key}", attr.Area, attr.Key); logger.LogWarning("Permission check failed: No authenticated user for {Key}", attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
await httpContext.Response.WriteAsync(UnauthorizedMessage); await httpContext.Response.WriteAsync(UnauthorizedMessage);
return; return;
@@ -46,33 +41,29 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
if (currentUser.IsSuperuser) if (currentUser.IsSuperuser)
{ {
// Bypass the permission check for performance // Bypass the permission check for performance
logger.LogDebug("Superuser {UserId} bypassing permission check for {Area}/{Key}", logger.LogDebug("Superuser {UserId} bypassing permission check for {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
await next(httpContext); await next(httpContext);
return; return;
} }
var actor = $"user:{currentUser.Id}"; var actor = currentUser.Id.ToString();
try try
{ {
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key); var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Key);
if (!permNode) if (!permNode)
{ {
logger.LogWarning("Permission denied for user {UserId}: {Area}/{Key}", logger.LogWarning("Permission denied for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync(ForbiddenMessage); await httpContext.Response.WriteAsync(ForbiddenMessage);
return; return;
} }
logger.LogDebug("Permission granted for user {UserId}: {Area}/{Key}", logger.LogDebug("Permission granted for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error checking permission for user {UserId}: {Area}/{Key}", logger.LogError(ex, "Error checking permission for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Permission check failed"); await httpContext.Response.WriteAsync("Permission check failed");
return; return;

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
@@ -28,8 +29,8 @@ public class PermissionService(
private const string PermissionGroupCacheKeyPrefix = "perm-cg:"; private const string PermissionGroupCacheKeyPrefix = "perm-cg:";
private const string PermissionGroupPrefix = "perm-g:"; private const string PermissionGroupPrefix = "perm-g:";
private static string GetPermissionCacheKey(string actor, string area, string key) => private static string GetPermissionCacheKey(string actor, string key) =>
PermissionCacheKeyPrefix + actor + ":" + area + ":" + key; PermissionCacheKeyPrefix + actor + ":" + key;
private static string GetGroupsCacheKey(string actor) => private static string GetGroupsCacheKey(string actor) =>
PermissionGroupCacheKeyPrefix + actor; PermissionGroupCacheKeyPrefix + actor;
@@ -37,50 +38,56 @@ public class PermissionService(
private static string GetPermissionGroupKey(string actor) => private static string GetPermissionGroupKey(string actor) =>
PermissionGroupPrefix + actor; PermissionGroupPrefix + actor;
public async Task<bool> HasPermissionAsync(string actor, string area, string key) public async Task<bool> HasPermissionAsync(
string actor,
string key,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
var value = await GetPermissionAsync<bool>(actor, area, key); var value = await GetPermissionAsync<bool>(actor, key, type);
return value; return value;
} }
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key) public async Task<T?> GetPermissionAsync<T>(
string actor,
string key,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
// Input validation // Input validation
if (string.IsNullOrWhiteSpace(actor)) if (string.IsNullOrWhiteSpace(actor))
throw new ArgumentException("Actor cannot be null or empty", nameof(actor)); throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
if (string.IsNullOrWhiteSpace(area))
throw new ArgumentException("Area cannot be null or empty", nameof(area));
if (string.IsNullOrWhiteSpace(key)) if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key)); throw new ArgumentException("Key cannot be null or empty", nameof(key));
var cacheKey = GetPermissionCacheKey(actor, area, key); var cacheKey = GetPermissionCacheKey(actor, key);
try try
{ {
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey); var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
if (hit) if (hit)
{ {
logger.LogDebug("Permission cache hit for {Actor}:{Area}:{Key}", actor, area, key); logger.LogDebug("Permission cache hit for {Type}:{Actor}:{Key}", type, actor, key);
return cachedValue; return cachedValue;
} }
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var groupsId = await GetOrCacheUserGroupsAsync(actor, now); var groupsId = await GetOrCacheUserGroupsAsync(actor, now);
var permission = await FindPermissionNodeAsync(actor, area, key, groupsId, now); var permission = await FindPermissionNodeAsync(type, actor, key, groupsId);
var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default; var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default;
await cache.SetWithGroupsAsync(cacheKey, result, await cache.SetWithGroupsAsync(cacheKey, result,
[GetPermissionGroupKey(actor)], [GetPermissionGroupKey(actor)],
_options.CacheExpiration); _options.CacheExpiration);
logger.LogDebug("Permission resolved for {Actor}:{Area}:{Key} = {Result}", logger.LogDebug("Permission resolved for {Type}:{Actor}:{Key} = {Result}", type, actor, key,
actor, area, key, result != null); result != null);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error retrieving permission for {Actor}:{Area}:{Key}", actor, area, key); logger.LogError(ex, "Error retrieving permission for {Type}:{Actor}:{Key}", type, actor, key);
throw; throw;
} }
} }
@@ -109,33 +116,34 @@ public class PermissionService(
return groupsId; return groupsId;
} }
private async Task<SnPermissionNode?> FindPermissionNodeAsync(string actor, string area, string key, private async Task<SnPermissionNode?> FindPermissionNodeAsync(
List<Guid> groupsId, Instant now) PermissionNodeActorType type,
string actor,
string key,
List<Guid> groupsId
)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
// First try exact match (highest priority) // First try exact match (highest priority)
var exactMatch = await db.PermissionNodes var exactMatch = await db.PermissionNodes
.Where(n => (n.GroupId == null && n.Actor == actor) || .Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.Key == key && n.Area == area) .Where(n => n.Key == key)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (exactMatch != null) if (exactMatch != null)
{
return exactMatch; return exactMatch;
}
// If no exact match and wildcards are enabled, try wildcard matches // If no exact match and wildcards are enabled, try wildcard matches
if (!_options.EnableWildcardMatching) if (!_options.EnableWildcardMatching)
{
return null; return null;
}
var wildcardMatches = await db.PermissionNodes var wildcardMatches = await db.PermissionNodes
.Where(n => (n.GroupId == null && n.Actor == actor) || .Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => (n.Key.Contains("*") || n.Area.Contains("*"))) .Where(n => EF.Functions.Like(n.Key, "%*%"))
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.Take(_options.MaxWildcardMatches) .Take(_options.MaxWildcardMatches)
@@ -147,36 +155,21 @@ public class PermissionService(
foreach (var node in wildcardMatches) foreach (var node in wildcardMatches)
{ {
var score = CalculateWildcardMatchScore(node.Area, node.Key, area, key); var score = CalculateWildcardMatchScore(node.Key, key);
if (score > bestMatchScore) if (score <= bestMatchScore) continue;
{ bestMatch = node;
bestMatch = node; bestMatchScore = score;
bestMatchScore = score;
}
} }
if (bestMatch != null) if (bestMatch != null)
{ logger.LogDebug("Found wildcard permission match: {NodeKey} for {Key}", bestMatch.Key, key);
logger.LogDebug("Found wildcard permission match: {NodeArea}:{NodeKey} for {Area}:{Key}",
bestMatch.Area, bestMatch.Key, area, key);
}
return bestMatch; return bestMatch;
} }
private static int CalculateWildcardMatchScore(string nodeArea, string nodeKey, string targetArea, string targetKey) private static int CalculateWildcardMatchScore(string nodeKey, string targetKey)
{ {
// Calculate how well the wildcard pattern matches return CalculatePatternMatchScore(nodeKey, targetKey);
// Higher score = better match
var areaScore = CalculatePatternMatchScore(nodeArea, targetArea);
var keyScore = CalculatePatternMatchScore(nodeKey, targetKey);
// Perfect match gets highest score
if (areaScore == int.MaxValue && keyScore == int.MaxValue)
return int.MaxValue;
// Prefer area matches over key matches, more specific patterns over general ones
return (areaScore * 1000) + keyScore;
} }
private static int CalculatePatternMatchScore(string pattern, string target) private static int CalculatePatternMatchScore(string pattern, string target)
@@ -184,31 +177,30 @@ public class PermissionService(
if (pattern == target) if (pattern == target)
return int.MaxValue; // Exact match return int.MaxValue; // Exact match
if (!pattern.Contains("*")) if (!pattern.Contains('*'))
return -1; // No wildcard, not a match return -1; // No wildcard, not a match
// Simple wildcard matching: * matches any sequence of characters // Simple wildcard matching: * matches any sequence of characters
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$"; var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$";
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); var regex = new System.Text.RegularExpressions.Regex(regexPattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (regex.IsMatch(target)) if (!regex.IsMatch(target)) return -1; // No match
{
// Score based on specificity (shorter patterns are less specific)
var wildcardCount = pattern.Count(c => c == '*');
var length = pattern.Length;
return Math.Max(1, 1000 - (wildcardCount * 100) - length);
}
return -1; // No match // Score based on specificity (shorter patterns are less specific)
var wildcardCount = pattern.Count(c => c == '*');
var length = pattern.Length;
return Math.Max(1, 1000 - wildcardCount * 100 - length);
} }
public async Task<SnPermissionNode> AddPermissionNode<T>( public async Task<SnPermissionNode> AddPermissionNode<T>(
string actor, string actor,
string area,
string key, string key,
T value, T value,
Instant? expiredAt = null, Instant? expiredAt = null,
Instant? affectedAt = null Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
) )
{ {
if (value is null) throw new ArgumentNullException(nameof(value)); if (value is null) throw new ArgumentNullException(nameof(value));
@@ -216,8 +208,8 @@ public class PermissionService(
var node = new SnPermissionNode var node = new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Type = type,
Key = key, Key = key,
Area = area,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
ExpiredAt = expiredAt, ExpiredAt = expiredAt,
AffectedAt = affectedAt AffectedAt = affectedAt
@@ -227,7 +219,7 @@ public class PermissionService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate related caches // Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
return node; return node;
} }
@@ -235,11 +227,11 @@ public class PermissionService(
public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>( public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>(
SnPermissionGroup group, SnPermissionGroup group,
string actor, string actor,
string area,
string key, string key,
T value, T value,
Instant? expiredAt = null, Instant? expiredAt = null,
Instant? affectedAt = null Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
) )
{ {
if (value is null) throw new ArgumentNullException(nameof(value)); if (value is null) throw new ArgumentNullException(nameof(value));
@@ -247,8 +239,8 @@ public class PermissionService(
var node = new SnPermissionNode var node = new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Type = type,
Key = key, Key = key,
Area = area,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
ExpiredAt = expiredAt, ExpiredAt = expiredAt,
AffectedAt = affectedAt, AffectedAt = affectedAt,
@@ -260,44 +252,45 @@ public class PermissionService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate related caches // Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(actor)); await cache.RemoveAsync(GetGroupsCacheKey(actor));
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor)); await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
return node; return node;
} }
public async Task RemovePermissionNode(string actor, string area, string key) public async Task RemovePermissionNode(string actor, string key, PermissionNodeActorType? type)
{ {
var node = await db.PermissionNodes var node = await db.PermissionNodes
.Where(n => n.Actor == actor && n.Area == area && n.Key == key) .Where(n => n.Actor == actor && n.Key == key)
.If(type is not null, q => q.Where(n => n.Type == type))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (node is not null) db.PermissionNodes.Remove(node); if (node is not null) db.PermissionNodes.Remove(node);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate cache // Invalidate cache
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
} }
public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string area, string key) public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string key)
{ {
var node = await db.PermissionNodes var node = await db.PermissionNodes
.Where(n => n.GroupId == group.Id) .Where(n => n.GroupId == group.Id)
.Where(n => n.Actor == actor && n.Area == area && n.Key == key) .Where(n => n.Actor == actor && n.Key == key && n.Type == PermissionNodeActorType.Group)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (node is null) return; if (node is null) return;
db.PermissionNodes.Remove(node); db.PermissionNodes.Remove(node);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate caches // Invalidate caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(actor)); await cache.RemoveAsync(GetGroupsCacheKey(actor));
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor)); await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
} }
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key) private async Task InvalidatePermissionCacheAsync(string actor, string key)
{ {
var cacheKey = GetPermissionCacheKey(actor, area, key); var cacheKey = GetPermissionCacheKey(actor, key);
await cache.RemoveAsync(cacheKey); await cache.RemoveAsync(cacheKey);
} }
@@ -312,12 +305,11 @@ public class PermissionService(
return JsonDocument.Parse(str); return JsonDocument.Parse(str);
} }
public static SnPermissionNode NewPermissionNode<T>(string actor, string area, string key, T value) public static SnPermissionNode NewPermissionNode<T>(string actor, string key, T value)
{ {
return new SnPermissionNode return new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Area = area,
Key = key, Key = key,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
}; };
@@ -341,8 +333,7 @@ public class PermissionService(
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area) .OrderBy(n => n.Key)
.ThenBy(n => n.Key)
.ToListAsync(); .ToListAsync();
logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor); logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor);
@@ -370,8 +361,7 @@ public class PermissionService(
.Where(n => n.GroupId == null && n.Actor == actor) .Where(n => n.GroupId == null && n.Actor == actor)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area) .OrderBy(n => n.Key)
.ThenBy(n => n.Key)
.ToListAsync(); .ToListAsync();
logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor); logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor);

View File

@@ -9,31 +9,33 @@ using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
public class PermissionServiceGrpc( public class PermissionServiceGrpc(
PermissionService permissionService, PermissionService psv,
AppDatabase db, AppDatabase db,
ILogger<PermissionServiceGrpc> logger ILogger<PermissionServiceGrpc> logger
) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase ) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase
{ {
public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context) public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var hasPermission = await permissionService.HasPermissionAsync(request.Actor, request.Area, request.Key); var hasPermission = await psv.HasPermissionAsync(request.Actor, request.Key, type);
return new HasPermissionResponse { HasPermission = hasPermission }; return new HasPermissionResponse { HasPermission = hasPermission };
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error checking permission for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error checking permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Permission check failed")); throw new RpcException(new Status(StatusCode.Internal, "Permission check failed"));
} }
} }
public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context) public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var permissionValue = await permissionService.GetPermissionAsync<JsonDocument>(request.Actor, request.Area, request.Key); var permissionValue = await psv.GetPermissionAsync<JsonDocument>(request.Actor, request.Key, type);
return new GetPermissionResponse return new GetPermissionResponse
{ {
Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null
@@ -41,14 +43,15 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error getting permission for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error getting permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission")); throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission"));
} }
} }
public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context) public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
JsonDocument jsonValue; JsonDocument jsonValue;
@@ -58,18 +61,18 @@ public class PermissionServiceGrpc(
} }
catch (JsonException ex) catch (JsonException ex)
{ {
logger.LogWarning(ex, "Invalid JSON in permission value for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format")); throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
} }
var node = await permissionService.AddPermissionNode( var node = await psv.AddPermissionNode(
request.Actor, request.Actor,
request.Area,
request.Key, request.Key,
jsonValue, jsonValue,
request.ExpiredAt?.ToInstant(), request.ExpiredAt?.ToInstant(),
request.AffectedAt?.ToInstant() request.AffectedAt?.ToInstant(),
type
); );
return new AddPermissionNodeResponse { Node = node.ToProtoValue() }; return new AddPermissionNodeResponse { Node = node.ToProtoValue() };
} }
@@ -79,14 +82,15 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error adding permission node for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node")); throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node"));
} }
} }
public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context) public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var group = await FindPermissionGroupAsync(request.Group.Id); var group = await FindPermissionGroupAsync(request.Group.Id);
@@ -102,19 +106,19 @@ public class PermissionServiceGrpc(
} }
catch (JsonException ex) catch (JsonException ex)
{ {
logger.LogWarning(ex, "Invalid JSON in permission value for group {GroupId}, actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format")); throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
} }
var node = await permissionService.AddPermissionNodeToGroup( var node = await psv.AddPermissionNodeToGroup(
group, group,
request.Actor, request.Actor,
request.Area,
request.Key, request.Key,
jsonValue, jsonValue,
request.ExpiredAt?.ToInstant(), request.ExpiredAt?.ToInstant(),
request.AffectedAt?.ToInstant() request.AffectedAt?.ToInstant(),
type
); );
return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() }; return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() };
} }
@@ -124,23 +128,24 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error adding permission node to group {GroupId} for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group")); throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group"));
} }
} }
public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context) public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
await permissionService.RemovePermissionNode(request.Actor, request.Area, request.Key); await psv.RemovePermissionNode(request.Actor, request.Key, type);
return new RemovePermissionNodeResponse { Success = true }; return new RemovePermissionNodeResponse { Success = true };
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error removing permission node for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error removing permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node")); throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node"));
} }
} }
@@ -155,7 +160,7 @@ public class PermissionServiceGrpc(
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found")); throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found"));
} }
await permissionService.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Area, request.Key); await psv.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Key);
return new RemovePermissionNodeFromGroupResponse { Success = true }; return new RemovePermissionNodeFromGroupResponse { Success = true };
} }
catch (RpcException) catch (RpcException)
@@ -164,20 +169,18 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error removing permission node from group {GroupId} for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error removing permission from group for {Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group")); throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group"));
} }
} }
private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId) private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId)
{ {
if (!Guid.TryParse(groupId, out var guid)) if (Guid.TryParse(groupId, out var guid))
{ return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId); logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId);
return null; return null;
}
return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
} }
} }

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Auth;
namespace DysonNetwork.Pass; namespace DysonNetwork.Pass;
@@ -19,16 +20,20 @@ public class PermissionController(
/// <summary> /// <summary>
/// Check if an actor has a specific permission /// Check if an actor has a specific permission
/// </summary> /// </summary>
[HttpGet("check/{actor}/{area}/{key}")] [HttpGet("check/{actor}/{key}")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<bool>(StatusCodes.Status200OK)] [ProducesResponseType<bool>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> CheckPermission(string actor, string area, string key) public async Task<IActionResult> CheckPermission(
[FromRoute] string actor,
[FromRoute] string key,
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
try try
{ {
var hasPermission = await permissionService.HasPermissionAsync(actor, area, key); var hasPermission = await permissionService.HasPermissionAsync(actor, key, type);
return Ok(hasPermission); return Ok(hasPermission);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
@@ -45,7 +50,7 @@ public class PermissionController(
/// Get all effective permissions for an actor (including group permissions) /// Get all effective permissions for an actor (including group permissions)
/// </summary> /// </summary>
[HttpGet("actors/{actor}/permissions/effective")] [HttpGet("actors/{actor}/permissions/effective")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -70,7 +75,7 @@ public class PermissionController(
/// Get all direct permissions for an actor (excluding group permissions) /// Get all direct permissions for an actor (excluding group permissions)
/// </summary> /// </summary>
[HttpGet("actors/{actor}/permissions/direct")] [HttpGet("actors/{actor}/permissions/direct")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -94,28 +99,27 @@ public class PermissionController(
/// <summary> /// <summary>
/// Give a permission to an actor /// Give a permission to an actor
/// </summary> /// </summary>
[HttpPost("actors/{actor}/permissions/{area}/{key}")] [HttpPost("actors/{actor}/permissions/{key}")]
[RequiredPermission("maintenance", "permissions.manage")] [AskPermission("permissions.manage")]
[ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)] [ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GivePermission( public async Task<IActionResult> GivePermission(
string actor, string actor,
string area,
string key, string key,
[FromBody] PermissionRequest request) [FromBody] PermissionRequest request
)
{ {
try try
{ {
var permission = await permissionService.AddPermissionNode( var permission = await permissionService.AddPermissionNode(
actor, actor,
area,
key, key,
JsonDocument.Parse(JsonSerializer.Serialize(request.Value)), JsonDocument.Parse(JsonSerializer.Serialize(request.Value)),
request.ExpiredAt, request.ExpiredAt,
request.AffectedAt request.AffectedAt
); );
return Created($"/api/permissions/actors/{actor}/permissions/{area}/{key}", permission); return Created($"/api/permissions/actors/{actor}/permissions/{key}", permission);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
@@ -130,16 +134,20 @@ public class PermissionController(
/// <summary> /// <summary>
/// Remove a permission from an actor /// Remove a permission from an actor
/// </summary> /// </summary>
[HttpDelete("actors/{actor}/permissions/{area}/{key}")] [HttpDelete("actors/{actor}/permissions/{key}")]
[RequiredPermission("maintenance", "permissions.manage")] [AskPermission("permissions.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> RemovePermission(string actor, string area, string key) public async Task<IActionResult> RemovePermission(
string actor,
string key,
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
try try
{ {
await permissionService.RemovePermissionNode(actor, area, key); await permissionService.RemovePermissionNode(actor, key, type);
return NoContent(); return NoContent();
} }
catch (ArgumentException ex) catch (ArgumentException ex)
@@ -156,7 +164,7 @@ public class PermissionController(
/// Get all groups for an actor /// Get all groups for an actor
/// </summary> /// </summary>
[HttpGet("actors/{actor}/groups")] [HttpGet("actors/{actor}/groups")]
[RequiredPermission("maintenance", "permissions.groups.check")] [AskPermission("permissions.groups.check")]
[ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -183,8 +191,8 @@ public class PermissionController(
/// <summary> /// <summary>
/// Add an actor to a permission group /// Add an actor to a permission group
/// </summary> /// </summary>
[HttpPost("actors/{actor}/groups/{groupId}")] [HttpPost("actors/{actor}/groups/{groupId:guid}")]
[RequiredPermission("maintenance", "permissions.groups.manage")] [AskPermission("permissions.groups.manage")]
[ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)] [ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -192,7 +200,8 @@ public class PermissionController(
public async Task<IActionResult> AddActorToGroup( public async Task<IActionResult> AddActorToGroup(
string actor, string actor,
Guid groupId, Guid groupId,
[FromBody] GroupMembershipRequest? request = null) [FromBody] GroupMembershipRequest? request = null
)
{ {
try try
{ {
@@ -238,7 +247,7 @@ public class PermissionController(
/// Remove an actor from a permission group /// Remove an actor from a permission group
/// </summary> /// </summary>
[HttpDelete("actors/{actor}/groups/{groupId}")] [HttpDelete("actors/{actor}/groups/{groupId}")]
[RequiredPermission("maintenance", "permissions.groups.manage")] [AskPermission("permissions.groups.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -272,7 +281,7 @@ public class PermissionController(
/// Clear permission cache for an actor /// Clear permission cache for an actor
/// </summary> /// </summary>
[HttpPost("actors/{actor}/cache/clear")] [HttpPost("actors/{actor}/cache/clear")]
[RequiredPermission("maintenance", "permissions.cache.manage")] [AskPermission("permissions.cache.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -297,7 +306,7 @@ public class PermissionController(
/// Validate a permission pattern /// Validate a permission pattern
/// </summary> /// </summary>
[HttpPost("validate-pattern")] [HttpPost("validate-pattern")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)] [ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult ValidatePattern([FromBody] PatternValidationRequest request) public IActionResult ValidatePattern([FromBody] PatternValidationRequest request)
@@ -322,14 +331,14 @@ public class PermissionController(
public class PermissionRequest public class PermissionRequest
{ {
public object? Value { get; set; } public object? Value { get; set; }
public NodaTime.Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public NodaTime.Instant? AffectedAt { get; set; } public Instant? AffectedAt { get; set; }
} }
public class GroupMembershipRequest public class GroupMembershipRequest
{ {
public NodaTime.Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public NodaTime.Instant? AffectedAt { get; set; } public Instant? AffectedAt { get; set; }
} }
public class PatternValidationRequest public class PatternValidationRequest

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -51,7 +52,7 @@ public class SnAbuseReportController(
[HttpGet("")] [HttpGet("")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<List<SnAbuseReport>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnAbuseReport>>(StatusCodes.Status200OK)]
public async Task<ActionResult<List<SnAbuseReport>>> GetReports( public async Task<ActionResult<List<SnAbuseReport>>> GetReports(
[FromQuery] int offset = 0, [FromQuery] int offset = 0,
@@ -85,7 +86,7 @@ public class SnAbuseReportController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SnAbuseReport>> GetReportById(Guid id) public async Task<ActionResult<SnAbuseReport>> GetReportById(Guid id)
@@ -122,7 +123,7 @@ public class SnAbuseReportController(
[HttpPost("{id}/resolve")] [HttpPost("{id}/resolve")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.resolve")] [AskPermission("reports.resolve")]
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SnAbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request) public async Task<ActionResult<SnAbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
@@ -144,7 +145,7 @@ public class SnAbuseReportController(
[HttpGet("count")] [HttpGet("count")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)] [ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount() public async Task<ActionResult<object>> GetReportsCount()
{ {

View File

@@ -22,7 +22,7 @@ public static class ApplicationConfiguration
app.UseWebSockets(); app.UseWebSockets();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<LocalPermissionMiddleware>();
app.MapControllers().RequireRateLimiting("fixed"); app.MapControllers().RequireRateLimiting("fixed");

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -196,7 +197,7 @@ public class WalletController(
[HttpPost("balance")] [HttpPost("balance")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")] [AskPermission("wallets.balance.modify")]
public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request) public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
{ {
var wallet = await ws.GetWalletAsync(request.AccountId); var wallet = await ws.GetWalletAsync(request.AccountId);

View File

@@ -139,7 +139,7 @@ public class NotificationController(
[HttpPost("send")] [HttpPost("send")]
[Authorize] [Authorize]
[RequiredPermission("global", "notifications.send")] [AskPermission("notifications.send")]
public async Task<ActionResult> SendNotification( public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request, [FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false [FromQuery] bool save = false

View File

@@ -1,72 +0,0 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Auth
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService, ILogger<PermissionMiddleware> logger)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// Assuming Account proto has a bool field 'is_superuser' which is generated as 'IsSuperuser'
if (currentUser.IsSuperuser)
{
// Bypass the permission check for performance
await next(httpContext);
return;
}
var actor = $"user:{currentUser.Id}";
try
{
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
{
Actor = actor,
Area = attr.Area,
Key = attr.Key
});
if (!permResp.HasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} was required.");
return;
}
}
catch (RpcException ex)
{
logger.LogError(ex, "gRPC call to PermissionService failed while checking permission {Area}/{Key} for actor {Actor}", attr.Area, attr.Key, actor);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Error checking permissions.");
return;
}
}
await next(httpContext);
}
}
}

View File

@@ -0,0 +1,72 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Auth;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class AskPermissionAttribute(string key, PermissionNodeActorType type = PermissionNodeActorType.Account)
: Attribute
{
public string Key { get; } = key;
public PermissionNodeActorType Type { get; } = type;
}
public class RemotePermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService,
ILogger<RemotePermissionMiddleware> logger)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<AskPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// Superuser will bypass all the permission check
if (currentUser.IsSuperuser)
{
await next(httpContext);
return;
}
try
{
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
{
Actor = currentUser.Id,
Key = attr.Key
});
if (!permResp.HasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Key} was required.");
return;
}
}
catch (RpcException ex)
{
logger.LogError(ex,
"gRPC call to PermissionService failed while checking permission {Key} for actor {Actor}", attr.Key,
currentUser.Id
);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Error checking permissions.");
return;
}
}
await next(httpContext);
}
}

View File

@@ -8,6 +8,12 @@ using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
public enum PermissionNodeActorType
{
Account,
Group
}
/// The permission node model provides the infrastructure of permission control in Dyson Network. /// The permission node model provides the infrastructure of permission control in Dyson Network.
/// It based on the ABAC permission model. /// It based on the ABAC permission model.
/// ///
@@ -19,12 +25,12 @@ namespace DysonNetwork.Shared.Models;
/// And the actor shows who owns the permission, in most cases, the user:&lt;userId&gt; /// And the actor shows who owns the permission, in most cases, the user:&lt;userId&gt;
/// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking /// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking
/// expect the member of that permission group inherent the permission from the group. /// expect the member of that permission group inherent the permission from the group.
[Index(nameof(Key), nameof(Area), nameof(Actor))] [Index(nameof(Key), nameof(Actor))]
public class SnPermissionNode : ModelBase, IDisposable public class SnPermissionNode : ModelBase, IDisposable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public PermissionNodeActorType Type { get; set; } = PermissionNodeActorType.Account;
[MaxLength(1024)] public string Actor { get; set; } = null!; [MaxLength(1024)] public string Actor { get; set; } = null!;
[MaxLength(1024)] public string Area { get; set; } = null!;
[MaxLength(1024)] public string Key { get; set; } = null!; [MaxLength(1024)] public string Key { get; set; } = null!;
[Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!; [Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!;
public Instant? ExpiredAt { get; set; } = null; public Instant? ExpiredAt { get; set; } = null;
@@ -39,7 +45,12 @@ public class SnPermissionNode : ModelBase, IDisposable
{ {
Id = Id.ToString(), Id = Id.ToString(),
Actor = Actor, Actor = Actor,
Area = Area, Type = Type switch
{
PermissionNodeActorType.Account => Proto.PermissionNodeActorType.Account,
PermissionNodeActorType.Group => Proto.PermissionNodeActorType.Group,
_ => throw new ArgumentOutOfRangeException()
},
Key = Key, Key = Key,
Value = Google.Protobuf.WellKnownTypes.Value.Parser.ParseJson(Value.RootElement.GetRawText()), Value = Google.Protobuf.WellKnownTypes.Value.Parser.ParseJson(Value.RootElement.GetRawText()),
ExpiredAt = ExpiredAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(),
@@ -48,6 +59,16 @@ public class SnPermissionNode : ModelBase, IDisposable
}; };
} }
public static PermissionNodeActorType ConvertProtoActorType(Proto.PermissionNodeActorType? val)
{
return val switch
{
Proto.PermissionNodeActorType.Account => PermissionNodeActorType.Account,
Proto.PermissionNodeActorType.Group => PermissionNodeActorType.Group,
_ => PermissionNodeActorType.Account
};
}
public void Dispose() public void Dispose()
{ {
Value.Dispose(); Value.Dispose();

View File

@@ -12,193 +12,197 @@ import "account.proto";
// Represents a user session // Represents a user session
message AuthSession { message AuthSession {
string id = 1; string id = 1;
optional google.protobuf.Timestamp last_granted_at = 3; optional google.protobuf.Timestamp last_granted_at = 3;
optional google.protobuf.Timestamp expired_at = 4; optional google.protobuf.Timestamp expired_at = 4;
string account_id = 5; string account_id = 5;
Account account = 6; Account account = 6;
string challenge_id = 7; string challenge_id = 7;
AuthChallenge challenge = 8; AuthChallenge challenge = 8;
google.protobuf.StringValue app_id = 9; google.protobuf.StringValue app_id = 9;
optional string client_id = 10; optional string client_id = 10;
optional string parent_session_id = 11; optional string parent_session_id = 11;
AuthClient client = 12; AuthClient client = 12;
} }
// Represents an authentication challenge // Represents an authentication challenge
message AuthChallenge { message AuthChallenge {
string id = 1; string id = 1;
google.protobuf.Timestamp expired_at = 2; google.protobuf.Timestamp expired_at = 2;
int32 step_remain = 3; int32 step_remain = 3;
int32 step_total = 4; int32 step_total = 4;
int32 failed_attempts = 5; int32 failed_attempts = 5;
ChallengeType type = 7; ChallengeType type = 7;
repeated string blacklist_factors = 8; repeated string blacklist_factors = 8;
repeated string audiences = 9; repeated string audiences = 9;
repeated string scopes = 10; repeated string scopes = 10;
google.protobuf.StringValue ip_address = 11; google.protobuf.StringValue ip_address = 11;
google.protobuf.StringValue user_agent = 12; google.protobuf.StringValue user_agent = 12;
google.protobuf.StringValue device_id = 13; google.protobuf.StringValue device_id = 13;
google.protobuf.StringValue nonce = 14; google.protobuf.StringValue nonce = 14;
// Point location is omitted as there is no direct proto equivalent. // Point location is omitted as there is no direct proto equivalent.
string account_id = 15; string account_id = 15;
google.protobuf.StringValue device_name = 16; google.protobuf.StringValue device_name = 16;
ClientPlatform platform = 17; ClientPlatform platform = 17;
} }
message AuthClient { message AuthClient {
string id = 1; string id = 1;
ClientPlatform platform = 2; ClientPlatform platform = 2;
google.protobuf.StringValue device_name = 3; google.protobuf.StringValue device_name = 3;
google.protobuf.StringValue device_label = 4; google.protobuf.StringValue device_label = 4;
string device_id = 5; string device_id = 5;
string account_id = 6; string account_id = 6;
} }
// Enum for challenge types // Enum for challenge types
enum ChallengeType { enum ChallengeType {
CHALLENGE_TYPE_UNSPECIFIED = 0; CHALLENGE_TYPE_UNSPECIFIED = 0;
LOGIN = 1; LOGIN = 1;
OAUTH = 2; OAUTH = 2;
OIDC = 3; OIDC = 3;
} }
// Enum for client platforms // Enum for client platforms
enum ClientPlatform { enum ClientPlatform {
CLIENT_PLATFORM_UNSPECIFIED = 0; CLIENT_PLATFORM_UNSPECIFIED = 0;
UNIDENTIFIED = 1; UNIDENTIFIED = 1;
WEB = 2; WEB = 2;
IOS = 3; IOS = 3;
ANDROID = 4; ANDROID = 4;
MACOS = 5; MACOS = 5;
WINDOWS = 6; WINDOWS = 6;
LINUX = 7; LINUX = 7;
} }
service AuthService { service AuthService {
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {} rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {}
rpc ValidatePin(ValidatePinRequest) returns (ValidateResponse) {} rpc ValidatePin(ValidatePinRequest) returns (ValidateResponse) {}
rpc ValidateCaptcha(ValidateCaptchaRequest) returns (ValidateResponse) {} rpc ValidateCaptcha(ValidateCaptchaRequest) returns (ValidateResponse) {}
} }
message AuthenticateRequest { message AuthenticateRequest {
string token = 1; string token = 1;
optional google.protobuf.StringValue ip_address = 2; optional google.protobuf.StringValue ip_address = 2;
} }
message AuthenticateResponse { message AuthenticateResponse {
bool valid = 1; bool valid = 1;
optional string message = 2; optional string message = 2;
optional AuthSession session = 3; optional AuthSession session = 3;
} }
message ValidatePinRequest { message ValidatePinRequest {
string account_id = 1; string account_id = 1;
string pin = 2; string pin = 2;
} }
message ValidateCaptchaRequest { message ValidateCaptchaRequest {
string token = 1; string token = 1;
} }
message ValidateResponse { message ValidateResponse {
bool valid = 1; bool valid = 1;
}
enum PermissionNodeActorType {
ACCOUNT = 0;
GROUP = 1;
} }
// Permission related messages and services // Permission related messages and services
message PermissionNode { message PermissionNode {
string id = 1; string id = 1;
string actor = 2; string actor = 2;
string area = 3; PermissionNodeActorType type = 3;
string key = 4; string key = 4;
google.protobuf.Value value = 5; // Using Value to represent generic type google.protobuf.Value value = 5; // Using Value to represent generic type
google.protobuf.Timestamp expired_at = 6; google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7; google.protobuf.Timestamp affected_at = 7;
string group_id = 8; // Optional group ID string group_id = 8; // Optional group ID
} }
message PermissionGroup { message PermissionGroup {
string id = 1; string id = 1;
string name = 2; string name = 2;
google.protobuf.Timestamp created_at = 3; google.protobuf.Timestamp created_at = 3;
} }
message HasPermissionRequest { message HasPermissionRequest {
string actor = 1; string actor = 1;
string area = 2; string key = 2;
string key = 3; optional PermissionNodeActorType type = 3;
} }
message HasPermissionResponse { message HasPermissionResponse {
bool has_permission = 1; bool has_permission = 1;
} }
message GetPermissionRequest { message GetPermissionRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
} }
message GetPermissionResponse { message GetPermissionResponse {
google.protobuf.Value value = 1; // Using Value to represent generic type google.protobuf.Value value = 1; // Using Value to represent generic type
} }
message AddPermissionNodeRequest { message AddPermissionNodeRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
google.protobuf.Value value = 4; google.protobuf.Value value = 4;
google.protobuf.Timestamp expired_at = 5; google.protobuf.Timestamp expired_at = 5;
google.protobuf.Timestamp affected_at = 6; google.protobuf.Timestamp affected_at = 6;
} }
message AddPermissionNodeResponse { message AddPermissionNodeResponse {
PermissionNode node = 1; PermissionNode node = 1;
} }
message AddPermissionNodeToGroupRequest { message AddPermissionNodeToGroupRequest {
PermissionGroup group = 1; PermissionGroup group = 1;
string actor = 2; string actor = 2;
string area = 3; optional PermissionNodeActorType type = 3;
string key = 4; string key = 4;
google.protobuf.Value value = 5; google.protobuf.Value value = 5;
google.protobuf.Timestamp expired_at = 6; google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7; google.protobuf.Timestamp affected_at = 7;
} }
message AddPermissionNodeToGroupResponse { message AddPermissionNodeToGroupResponse {
PermissionNode node = 1; PermissionNode node = 1;
} }
message RemovePermissionNodeRequest { message RemovePermissionNodeRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
} }
message RemovePermissionNodeResponse { message RemovePermissionNodeResponse {
bool success = 1; bool success = 1;
} }
message RemovePermissionNodeFromGroupRequest { message RemovePermissionNodeFromGroupRequest {
PermissionGroup group = 1; PermissionGroup group = 1;
string actor = 2; string actor = 2;
string area = 3; string key = 4;
string key = 4;
} }
message RemovePermissionNodeFromGroupResponse { message RemovePermissionNodeFromGroupResponse {
bool success = 1; bool success = 1;
} }
service PermissionService { service PermissionService {
rpc HasPermission(HasPermissionRequest) returns (HasPermissionResponse) {} rpc HasPermission(HasPermissionRequest) returns (HasPermissionResponse) {}
rpc GetPermission(GetPermissionRequest) returns (GetPermissionResponse) {} rpc GetPermission(GetPermissionRequest) returns (GetPermissionResponse) {}
rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {} rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {}
rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {} rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {}
rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {} rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {}
rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest) rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest)
returns (RemovePermissionNodeFromGroupResponse) {} returns (RemovePermissionNodeFromGroupResponse) {}
} }

View File

@@ -243,7 +243,7 @@ public partial class ChatController(
[HttpPost("{roomId:guid}/messages")] [HttpPost("{roomId:guid}/messages")]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.messages.create")] [AskPermission("chat.messages.create")]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId) public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();

View File

@@ -179,7 +179,7 @@ public class ChatRoomController(
[HttpPost] [HttpPost]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.create")] [AskPermission("chat.create")]
public async Task<ActionResult<SnChatRoom>> CreateChatRoom(ChatRoomRequest request) public async Task<ActionResult<SnChatRoom>> CreateChatRoom(ChatRoomRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();

View File

@@ -525,7 +525,7 @@ public class PostController(
} }
[HttpPost] [HttpPost]
[RequiredPermission("global", "posts.create")] [AskPermission("posts.create")]
public async Task<ActionResult<SnPost>> CreatePost( public async Task<ActionResult<SnPost>> CreatePost(
[FromBody] PostRequest request, [FromBody] PostRequest request,
[FromQuery(Name = "pub")] string? pubName [FromQuery(Name = "pub")] string? pubName
@@ -725,7 +725,7 @@ public class PostController(
[HttpPost("{id:guid}/reactions")] [HttpPost("{id:guid}/reactions")]
[Authorize] [Authorize]
[RequiredPermission("global", "posts.react")] [AskPermission("posts.react")]
public async Task<ActionResult<SnPostReaction>> ReactPost( public async Task<ActionResult<SnPostReaction>> ReactPost(
Guid id, Guid id,
[FromBody] PostReactionRequest request [FromBody] PostReactionRequest request

View File

@@ -341,7 +341,7 @@ public class PublisherController(
[HttpPost("individual")] [HttpPost("individual")]
[Authorize] [Authorize]
[RequiredPermission("global", "publishers.create")] [AskPermission("publishers.create")]
public async Task<ActionResult<SnPublisher>> CreatePublisherIndividual( public async Task<ActionResult<SnPublisher>> CreatePublisherIndividual(
[FromBody] PublisherRequest request [FromBody] PublisherRequest request
) )
@@ -426,7 +426,7 @@ public class PublisherController(
[HttpPost("organization/{realmSlug}")] [HttpPost("organization/{realmSlug}")]
[Authorize] [Authorize]
[RequiredPermission("global", "publishers.create")] [AskPermission("publishers.create")]
public async Task<ActionResult<SnPublisher>> CreatePublisherOrganization( public async Task<ActionResult<SnPublisher>> CreatePublisherOrganization(
string realmSlug, string realmSlug,
[FromBody] PublisherRequest request [FromBody] PublisherRequest request
@@ -833,7 +833,7 @@ public class PublisherController(
[HttpPost("{name}/features")] [HttpPost("{name}/features")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "publishers.features")] [AskPermission("publishers.features")]
public async Task<ActionResult<PublisherFeature>> AddPublisherFeature( public async Task<ActionResult<PublisherFeature>> AddPublisherFeature(
string name, string name,
[FromBody] PublisherFeatureRequest request [FromBody] PublisherFeatureRequest request
@@ -858,7 +858,7 @@ public class PublisherController(
[HttpDelete("{name}/features/{flag}")] [HttpDelete("{name}/features/{flag}")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "publishers.features")] [AskPermission("publishers.features")]
public async Task<ActionResult> RemovePublisherFeature(string name, string flag) public async Task<ActionResult> RemovePublisherFeature(string name, string flag)
{ {
var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync(); var publisher = await db.Publishers.Where(p => p.Name == name).FirstOrDefaultAsync();
@@ -880,7 +880,7 @@ public class PublisherController(
[HttpPost("rewards/settle")] [HttpPost("rewards/settle")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "publishers.reward.settle")] [AskPermission("publishers.reward.settle")]
public async Task<IActionResult> PerformLotteryDraw() public async Task<IActionResult> PerformLotteryDraw()
{ {
await ps.SettlePublisherRewards(); await ps.SettlePublisherRewards();

View File

@@ -16,7 +16,7 @@ public static class ApplicationConfiguration
app.UseWebSockets(); app.UseWebSockets();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<RemotePermissionMiddleware>();
app.MapControllers(); app.MapControllers();

View File

@@ -120,7 +120,7 @@ public class StickerController(
} }
[HttpPost] [HttpPost]
[RequiredPermission("global", "stickers.packs.create")] [AskPermission("stickers.packs.create")]
public async Task<ActionResult<StickerPack>> CreateStickerPack( public async Task<ActionResult<StickerPack>> CreateStickerPack(
[FromBody] StickerPackRequest request, [FromBody] StickerPackRequest request,
[FromQuery(Name = "pub")] string publisherName [FromQuery(Name = "pub")] string publisherName
@@ -334,7 +334,7 @@ public class StickerController(
public const int MaxStickersPerPack = 24; public const int MaxStickersPerPack = 24;
[HttpPost("{packId:guid}/content")] [HttpPost("{packId:guid}/content")]
[RequiredPermission("global", "stickers.create")] [AskPermission("stickers.create")]
public async Task<IActionResult> CreateSticker(Guid packId, [FromBody] StickerRequest request) public async Task<IActionResult> CreateSticker(Guid packId, [FromBody] StickerRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)

View File

@@ -59,7 +59,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary> /// </summary>
[HttpDelete("link/cache")] [HttpDelete("link/cache")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "cache.scrap")] [AskPermission("cache.scrap")]
public async Task<IActionResult> InvalidateCache([FromQuery] string url) public async Task<IActionResult> InvalidateCache([FromQuery] string url)
{ {
if (string.IsNullOrEmpty(url)) if (string.IsNullOrEmpty(url))
@@ -76,7 +76,7 @@ public class WebReaderController(WebReaderService reader, ILogger<WebReaderContr
/// </summary> /// </summary>
[HttpDelete("cache/all")] [HttpDelete("cache/all")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "cache.scrap")] [AskPermission("cache.scrap")]
public async Task<IActionResult> InvalidateAllCache() public async Task<IActionResult> InvalidateAllCache()
{ {
await reader.InvalidateAllCachedPreviewsAsync(); await reader.InvalidateAllCachedPreviewsAsync();

View File

@@ -14,7 +14,7 @@ public static class ApplicationConfiguration
app.UseWebSockets(); app.UseWebSockets();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<RemotePermissionMiddleware>();
app.MapControllers(); app.MapControllers();