diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index ac59b15a..1d31bba6 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -24,7 +24,7 @@ public sealed partial class AuthenticatedAccountController public async Task ChangeUsername(ChangeUsernameRequest data) { var result = await _accountService.ChangeUsername(CurrentUser.Id, data.Username, - CurrentUser.Rank.IsAllowed(RankType.Staff)); + CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System)); return result.Match( success => Ok(), diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 0745f3e8..8906d440 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -27,7 +27,7 @@ public async Task DeleteUser([FromRoute] Guid userId) return Problem(AdminError.UserNotFound); } - if (user.Rank >= RankType.Admin) + if (user.Roles.Any(r => r is RoleType.Admin or RoleType.System)) { return Problem(AdminError.CannotDeletePrivledgedAccount); } diff --git a/API/Controller/Admin/_ApiController.cs b/API/Controller/Admin/_ApiController.cs index ac6c33fe..aa5f5580 100644 --- a/API/Controller/Admin/_ApiController.cs +++ b/API/Controller/Admin/_ApiController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; +using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using Redis.OM.Contracts; @@ -9,7 +10,7 @@ namespace OpenShock.API.Controller.Admin; [ApiController] [Route("/{version:apiVersion}/admin")] -[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie, Policy = OpenShockAuthPolicies.AdminAccess)] +[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie, Roles = "Admin")] public sealed partial class AdminController : AuthenticatedSessionControllerBase { private readonly OpenShockContext _db; diff --git a/API/Controller/Devices/DeviceOtaController.cs b/API/Controller/Devices/DeviceOtaController.cs index b38f08f0..ca6b13f1 100644 --- a/API/Controller/Devices/DeviceOtaController.cs +++ b/API/Controller/Devices/DeviceOtaController.cs @@ -6,16 +6,28 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.Attributes; +using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Errors; using OpenShock.Common.Models; using OpenShock.Common.Models.Services.Ota; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Services.Ota; namespace OpenShock.API.Controller.Devices; -public sealed partial class DevicesController +[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie)] +public sealed class DevicesOtaController : AuthenticatedSessionControllerBase { + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public DevicesOtaController(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + /// /// Gets the OTA update history for a device /// @@ -25,7 +37,6 @@ public sealed partial class DevicesController /// Could not find device or you do not have access to it [HttpGet("{deviceId}/ota")] [MapToApiVersion("1")] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] [ProducesResponseType>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound public async Task GetOtaUpdateHistory([FromRoute] Guid deviceId, [FromServices] IOtaService otaService) diff --git a/API/Controller/Devices/DevicesController.cs b/API/Controller/Devices/DevicesController.cs index 0eaf5e3b..e30cf76a 100644 --- a/API/Controller/Devices/DevicesController.cs +++ b/API/Controller/Devices/DevicesController.cs @@ -132,7 +132,7 @@ public async Task RegenerateDeviceToken([FromRoute] Guid deviceId [MapToApiVersion("1")] public async Task RemoveDevice([FromRoute] Guid deviceId, [FromServices] IDeviceUpdateService updateService) { - var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrAdmin(x => x.OwnerNavigation, CurrentUser).ExecuteDeleteAsync(); + var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrPrivileged(x => x.OwnerNavigation, CurrentUser).ExecuteDeleteAsync(); if (affected <= 0) return Problem(DeviceError.DeviceNotFound); await updateService.UpdateDeviceForAllShared(CurrentUser.Id, deviceId, DeviceUpdateType.Deleted); diff --git a/API/Controller/Sessions/DeleteSessions.cs b/API/Controller/Sessions/DeleteSessions.cs index 9446e4c8..1cbd72da 100644 --- a/API/Controller/Sessions/DeleteSessions.cs +++ b/API/Controller/Sessions/DeleteSessions.cs @@ -17,7 +17,7 @@ public async Task DeleteSession(Guid sessionId) var loginSession = await _sessionService.GetSessionByPulbicId(sessionId); // If the session was not found, or the user does not have the privledges to access it, return NotFound - if (loginSession == null || !CurrentUser.IsUserOrRank(loginSession.UserId, RankType.Admin)) + if (loginSession == null || !CurrentUser.IsUserOrRole(loginSession.UserId, RoleType.Admin)) { return Problem(SessionError.SessionNotFound); } diff --git a/API/Controller/Shares/DeleteShareCode.cs b/API/Controller/Shares/DeleteShareCode.cs index c3d8db64..74fe480c 100644 --- a/API/Controller/Shares/DeleteShareCode.cs +++ b/API/Controller/Shares/DeleteShareCode.cs @@ -26,7 +26,7 @@ public async Task DeleteShareCode([FromRoute] Guid shareCodeId) { var affected = await _db.ShockerShareCodes .Where(x => x.Id == shareCodeId) - .WhereIsUserOrAdmin(x => x.Shocker.DeviceNavigation.OwnerNavigation, CurrentUser) + .WhereIsUserOrPrivileged(x => x.Shocker.DeviceNavigation.OwnerNavigation, CurrentUser) .ExecuteDeleteAsync(); if (affected <= 0) { diff --git a/API/Controller/Shares/Links/DeleteShareLink.cs b/API/Controller/Shares/Links/DeleteShareLink.cs index 2ad0c92c..92a63628 100644 --- a/API/Controller/Shares/Links/DeleteShareLink.cs +++ b/API/Controller/Shares/Links/DeleteShareLink.cs @@ -24,7 +24,7 @@ public async Task DeleteShareLink([FromRoute] Guid shareLinkId) { var result = await _db.ShockerSharesLinks .Where(x => x.Id == shareLinkId) - .WhereIsUserOrAdmin(x => x.Owner, CurrentUser) + .WhereIsUserOrPrivileged(x => x.Owner, CurrentUser) .ExecuteDeleteAsync(); return result > 0 diff --git a/API/Controller/Shares/Links/DeleteShockerShareLink.cs b/API/Controller/Shares/Links/DeleteShockerShareLink.cs index 63a97474..1b485f81 100644 --- a/API/Controller/Shares/Links/DeleteShockerShareLink.cs +++ b/API/Controller/Shares/Links/DeleteShockerShareLink.cs @@ -26,7 +26,7 @@ public async Task RemoveShocker([FromRoute] Guid shareLinkId, [Fr { var exists = await _db.ShockerSharesLinks .Where(x => x.Id == shareLinkId) - .WhereIsUserOrAdmin(x => x.Owner, CurrentUser) + .WhereIsUserOrPrivileged(x => x.Owner, CurrentUser) .AnyAsync(); if (!exists) { diff --git a/API/Controller/Shockers/DeleteShockerController.cs b/API/Controller/Shockers/DeleteShockerController.cs index ee2171d8..da67e9c6 100644 --- a/API/Controller/Shockers/DeleteShockerController.cs +++ b/API/Controller/Shockers/DeleteShockerController.cs @@ -32,7 +32,7 @@ public async Task RemoveShocker( { var affected = await _db.Shockers .Where(x => x.Id == shockerId) - .WhereIsUserOrAdmin(x => x.DeviceNavigation.OwnerNavigation, CurrentUser) + .WhereIsUserOrPrivileged(x => x.DeviceNavigation.OwnerNavigation, CurrentUser) .FirstOrDefaultAsync(); if (affected == null) diff --git a/API/Controller/Tokens/TokenController.cs b/API/Controller/Tokens/TokenController.cs index 9d42ea43..7e6d9b41 100644 --- a/API/Controller/Tokens/TokenController.cs +++ b/API/Controller/Tokens/TokenController.cs @@ -25,11 +25,10 @@ public sealed partial class TokensController /// /// All tokens for the current user [HttpGet] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task ListTokens() + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task> ListTokens() { - return await _db.ApiTokens + var apiTokens = await _db.ApiTokens .Where(x => x.UserId == CurrentUser.Id && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)) .OrderBy(x => x.CreatedOn) .Select(x => new TokenResponse @@ -40,7 +39,9 @@ public async Task ListTokens() Permissions = x.Permissions, Name = x.Name, Id = x.Id - }).ToArrayAsync(); + }).ToListAsync(); + + return apiTokens; } /// @@ -50,7 +51,6 @@ public async Task ListTokens() /// The token /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound public async Task GetTokenById([FromRoute] Guid tokenId) @@ -79,14 +79,13 @@ public async Task GetTokenById([FromRoute] Guid tokenId) /// Successfully deleted token /// The token does not exist or you do not have access to it. [HttpDelete("{tokenId}")] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound public async Task DeleteToken([FromRoute] Guid tokenId) { var apiToken = await _db.ApiTokens .Where(x => x.Id == tokenId) - .WhereIsUserOrAdmin(x => x.User, CurrentUser) + .WhereIsUserOrPrivileged(x => x.User, CurrentUser) .ExecuteDeleteAsync(); if (apiToken <= 0) @@ -103,7 +102,6 @@ public async Task DeleteToken([FromRoute] Guid tokenId) /// /// The created token [HttpPost] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] public async Task CreateToken([FromBody] CreateTokenRequest body) { @@ -137,7 +135,6 @@ public async Task CreateToken([FromBody] CreateTokenReques /// The edited token /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}")] - [Authorize(Policy = OpenShockAuthPolicies.UserAccess)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body) diff --git a/API/Controller/Tokens/TokenSelfController.cs b/API/Controller/Tokens/TokenSelfController.cs index 587e3d8f..c04b5a54 100644 --- a/API/Controller/Tokens/TokenSelfController.cs +++ b/API/Controller/Tokens/TokenSelfController.cs @@ -4,13 +4,17 @@ using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.Attributes; +using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Authentication.Services; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; namespace OpenShock.API.Controller.Tokens; -public sealed partial class TokensController +[ApiController] +[Route("/{version:apiVersion}/tokens")] +[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.ApiToken)] +public sealed partial class TokensSelfController : AuthenticatedSessionControllerBase { /// /// Gets information about the current token used to access this endpoint @@ -19,7 +23,6 @@ public sealed partial class TokensController /// /// [HttpGet("self")] - [Authorize(Policy = OpenShockAuthPolicies.TokenSessionOnly)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] public TokenResponse GetSelfToken([FromServices] IUserReferenceService userReferenceService) { diff --git a/API/Controller/Tokens/_ApiController.cs b/API/Controller/Tokens/_ApiController.cs index c7b33cfe..1465aa9e 100644 --- a/API/Controller/Tokens/_ApiController.cs +++ b/API/Controller/Tokens/_ApiController.cs @@ -3,23 +3,20 @@ using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.OpenShockDb; -using Redis.OM.Contracts; namespace OpenShock.API.Controller.Tokens; [ApiController] [Route("/{version:apiVersion}/tokens")] -[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionApiTokenCombo)] +[Authorize(AuthenticationSchemes = OpenShockAuthSchemas.UserSessionCookie)] public sealed partial class TokensController : AuthenticatedSessionControllerBase { private readonly OpenShockContext _db; - private readonly IRedisConnectionProvider _redis; private readonly ILogger _logger; - public TokensController(OpenShockContext db, IRedisConnectionProvider redis, ILogger logger) + public TokensController(OpenShockContext db, ILogger logger) { _db = db; - _redis = redis; _logger = logger; } } \ No newline at end of file diff --git a/API/Controller/Users/GetSelf.cs b/API/Controller/Users/GetSelf.cs index b22f542b..f3a0081b 100644 --- a/API/Controller/Users/GetSelf.cs +++ b/API/Controller/Users/GetSelf.cs @@ -21,7 +21,7 @@ public sealed partial class UsersController Name = CurrentUser.Name, Email = CurrentUser.Email, Image = CurrentUser.GetImageUrl(), - Rank = CurrentUser.Rank + Roles = CurrentUser.Roles } }; public sealed class SelfResponse @@ -30,6 +30,6 @@ public sealed class SelfResponse public required string Name { get; set; } public required string Email { get; set; } public required Uri Image { get; set; } - public required RankType Rank { get; set; } + public required List Roles { get; set; } } } \ No newline at end of file diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index fd691a78..3c0ce0a0 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication; +using System.Net.Mime; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -10,6 +11,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; +using OpenShock.Common.Problems; namespace OpenShock.Common.Authentication.AuthenticationHandlers; @@ -20,6 +22,7 @@ public sealed class ApiTokenAuthentication : AuthenticationHandler options, @@ -42,14 +45,14 @@ protected override async Task HandleAuthenticateAsync() { if (!Context.TryGetApiTokenFromHeader(out var token)) { - return AuthenticateResult.Fail(AuthResultError.HeaderMissingOrInvalid.Title!); + return Fail(AuthResultError.HeaderMissingOrInvalid); } - string tokenHash = HashingUtils.HashSha256(token); + var tokenHash = HashingUtils.HashSha256(token); var tokenDto = await _db.ApiTokens.Include(x => x.User).FirstOrDefaultAsync(x => x.TokenHash == tokenHash && (x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow)); - if (tokenDto == null) return AuthenticateResult.Fail(AuthResultError.TokenInvalid.Title!); + if (tokenDto == null) return Fail(AuthResultError.TokenInvalid); _batchUpdateService.UpdateTokenLastUsed(tokenDto.Id); _authService.CurrentClient = tokenDto.User; @@ -74,4 +77,20 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Success(ticket); } + + private AuthenticateResult Fail(OpenShockProblem reason) + { + _authResultError = reason; + return AuthenticateResult.Fail(reason.Type!); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (Context.Response.HasStarted) return Task.CompletedTask; + _authResultError ??= AuthResultError.UnknownError; + Response.StatusCode = _authResultError.Status!.Value; + _authResultError.AddContext(Context); + return Context.Response.WriteAsJsonAsync(_authResultError, _serializerOptions, contentType: MediaTypeNames.Application.ProblemJson); + } } \ No newline at end of file diff --git a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs index 886d3ede..bfba0d65 100644 --- a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs @@ -72,6 +72,7 @@ private AuthenticateResult Fail(OpenShockProblem reason) /// protected override Task HandleChallengeAsync(AuthenticationProperties properties) { + if (Context.Response.HasStarted) return Task.CompletedTask; _authResultError ??= AuthResultError.UnknownError; Response.StatusCode = _authResultError.Status!.Value; _authResultError.AddContext(Context); diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index 610dd51d..ff43ea72 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -25,6 +25,7 @@ public sealed class UserSessionAuthentication : AuthenticationHandler options, @@ -49,11 +50,11 @@ protected override async Task HandleAuthenticateAsync() { if (!Context.TryGetUserSession(out var sessionKey)) { - return AuthenticateResult.Fail(AuthResultError.CookieMissingOrInvalid.Type!); + return Fail(AuthResultError.CookieMissingOrInvalid); } var session = await _sessionService.GetSessionById(sessionKey); - if (session == null) return AuthenticateResult.Fail(AuthResultError.SessionInvalid.Type!); + if (session == null) return Fail(AuthResultError.SessionInvalid); if (session.Expires!.Value < DateTime.UtcNow.Subtract(Duration.LoginSessionExpansionAfter)) { @@ -76,9 +77,10 @@ protected override async Task HandleAuthenticateAsync() List claims = [ new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemas.UserSessionCookie), new(ClaimTypes.NameIdentifier, retrievedUser.Id.ToString()), - new(ClaimTypes.Role, retrievedUser.Rank.ToString()) ]; + claims.AddRange(retrievedUser.Roles.Select(r => new Claim(ClaimTypes.Role, r.ToString()))); + var ident = new ClaimsIdentity(claims, nameof(UserSessionAuthentication)); Context.User = new ClaimsPrincipal(ident); @@ -87,4 +89,20 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Success(ticket); } + + private AuthenticateResult Fail(OpenShockProblem reason) + { + _authResultError = reason; + return AuthenticateResult.Fail(reason.Type!); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (Context.Response.HasStarted) return Task.CompletedTask; + _authResultError ??= AuthResultError.UnknownError; + Response.StatusCode = _authResultError.Status!.Value; + _authResultError.AddContext(Context); + return Context.Response.WriteAsJsonAsync(_authResultError, _serializerOptions, contentType: MediaTypeNames.Application.ProblemJson); + } } \ No newline at end of file diff --git a/Common/Authentication/OpenShockAuthPolicies.cs b/Common/Authentication/OpenShockAuthPolicies.cs index eddc6a03..44992c24 100644 --- a/Common/Authentication/OpenShockAuthPolicies.cs +++ b/Common/Authentication/OpenShockAuthPolicies.cs @@ -2,11 +2,5 @@ public static class OpenShockAuthPolicies { - public const string UserAccess = "UserAccess"; - public const string SupportAccess = "SupportAccess"; - public const string StaffAccess = "StaffAccess"; - public const string AdminAccess = "AdminAccess"; - public const string SystemAccess = "SystemAccess"; - - public const string TokenSessionOnly = "ApiTokenOnly"; + public const string RankAdmin = "AdminOnly"; } diff --git a/Common/Authentication/OpenShockAuthorizationMiddlewareResultHandler.cs b/Common/Authentication/OpenShockAuthorizationMiddlewareResultHandler.cs new file mode 100644 index 00000000..bc4c84ae --- /dev/null +++ b/Common/Authentication/OpenShockAuthorizationMiddlewareResultHandler.cs @@ -0,0 +1,37 @@ +using System.Net.Mime; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Options; +using OpenShock.Common.Errors; + +namespace OpenShock.Common.Authentication; + +public class OpenShockAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler +{ + private readonly JsonSerializerOptions _serializerOptions; + + private readonly AuthorizationMiddlewareResultHandler + _defaultHandler = new AuthorizationMiddlewareResultHandler(); + + public OpenShockAuthorizationMiddlewareResultHandler(IOptions jsonOptions) + { + _serializerOptions = jsonOptions.Value.SerializerOptions; + } + + public Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + if (authorizeResult.Forbidden) + { + var failedRequirements = authorizeResult.AuthorizationFailure?.FailedRequirements.Select(x => x.ToString() ?? "error") ?? Array.Empty(); + var problem = AuthorizationError.PolicyNotMet(failedRequirements); + context.Response.StatusCode = problem.Status!.Value; + problem.AddContext(context); + return context.Response.WriteAsJsonAsync(problem, _serializerOptions, contentType: MediaTypeNames.Application.ProblemJson); + } + + return _defaultHandler.HandleAsync(next, context, policy, authorizeResult); + } +} \ No newline at end of file diff --git a/Common/Errors/AuthorizationError.cs b/Common/Errors/AuthorizationError.cs index 25f96f6e..a0d4dc7b 100644 --- a/Common/Errors/AuthorizationError.cs +++ b/Common/Errors/AuthorizationError.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Authorization; using OpenShock.Common.Models; using OpenShock.Common.Problems; using OpenShock.Common.Problems.CustomProblems; @@ -20,4 +21,6 @@ public static TokenPermissionProblem TokenPermissionMissing(PermissionType requi IEnumerable grantedPermissions) => new("Authorization.Token.PermissionMissing", $"You do not have the required permission to access this endpoint. Missing permission: {requiredPermission.ToString()}", requiredPermission, grantedPermissions, HttpStatusCode.Forbidden); + + public static PolicyNotMetProblem PolicyNotMet(IEnumerable failedRequirements) => new PolicyNotMetProblem(failedRequirements); } \ No newline at end of file diff --git a/Common/Extensions/UserExtensions.cs b/Common/Extensions/UserExtensions.cs index aca3ec4f..85068377 100644 --- a/Common/Extensions/UserExtensions.cs +++ b/Common/Extensions/UserExtensions.cs @@ -18,22 +18,18 @@ public static bool IsUser(this User user, User otherUser) return user == otherUser || user.Id == otherUser.Id; } - public static bool IsRank(this User user, RankType rank) + public static bool IsRole(this User user, RoleType role) { - return user.Rank >= rank; + return user.Roles.Contains(role); } - public static bool IsUserOrRank(this User user, User otherUser, RankType rank) + public static bool IsUserOrRole(this User user, Guid otherUserId, RoleType role) { - if (user.IsUser(otherUser)) return true; - - return user.Rank >= rank; + return user.IsUser(otherUserId) || user.IsRole(role); } - public static bool IsUserOrRank(this User user, Guid otherUserId, RankType rank) + public static bool IsUserOrRole(this User user, User otherUser, RoleType role) { - if (user.IsUser(otherUserId)) return true; - - return user.Rank >= rank; + return user.IsUser(otherUser) || user.IsRole(role); } -} \ No newline at end of file +} diff --git a/Common/IQueryableExtensions.cs b/Common/IQueryableExtensions.cs index a1a05f2b..fc47e700 100644 --- a/Common/IQueryableExtensions.cs +++ b/Common/IQueryableExtensions.cs @@ -38,18 +38,13 @@ public static IQueryable WhereIsUser(this IQueryable return source.Where(IsUserMatchExpr(navigationSelector, userId)); } - public static IQueryable WhereIsUserOrRank(this IQueryable source, Expression> navigationSelector, User user, RankType rank) + public static IQueryable WhereIsUserOrPrivileged(this IQueryable source, Expression> navigationSelector, User user) { - if (user.Rank >= rank) + if (user.Roles.Any(r => r is RoleType.Admin or RoleType.System)) { return source; } return WhereIsUser(source, navigationSelector, user.Id); } - - public static IQueryable WhereIsUserOrAdmin(this IQueryable source, Expression> navigationSelector, User user) - { - return WhereIsUserOrRank(source, navigationSelector, user, RankType.Admin); - } } diff --git a/Common/Migrations/20250203224107_RanksToRoles.Designer.cs b/Common/Migrations/20250203224107_RanksToRoles.Designer.cs new file mode 100644 index 00000000..3c035be9 --- /dev/null +++ b/Common/Migrations/20250203224107_RanksToRoles.Designer.cs @@ -0,0 +1,1131 @@ +// +using System; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(OpenShockContext))] + [Migration("20250203224107_RanksToRoles")] + partial class RanksToRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailActivated") + .HasColumnType("boolean") + .HasColumnName("email_activated"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_share_count"); + + b.Property("ShockerShareLinkCount") + .HasColumnType("integer") + .HasColumnName("shocker_share_link_count"); + + b.Property("UserActivationCount") + .HasColumnType("integer") + .HasColumnName("user_activation_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used") + .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("ValidUntil") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Owner") + .HasColumnType("uuid") + .HasColumnName("owner"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("Owner") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("Device") + .HasColumnType("uuid") + .HasColumnName("device"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("Device", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedOn" }, "device_ota_updates_created_on_idx") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("secret"); + + b.Property("UsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_on"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("password_resets_pkey"); + + b.HasIndex("UserId") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShareRequest", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Owner") + .HasColumnType("uuid") + .HasColumnName("owner"); + + b.Property("User") + .HasColumnType("uuid") + .HasColumnName("user"); + + b.HasKey("Id") + .HasName("shares_codes_pkey"); + + b.HasIndex("Owner") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("User"); + + b.ToTable("share_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShareRequestsShocker", b => + { + b.Property("ShareRequest") + .HasColumnType("uuid") + .HasColumnName("share_request"); + + b.Property("Shocker") + .HasColumnType("uuid") + .HasColumnName("shocker"); + + b.Property("LimitDuration") + .HasColumnType("integer") + .HasColumnName("limit_duration"); + + b.Property("LimitIntensity") + .HasColumnType("smallint") + .HasColumnName("limit_intensity"); + + b.Property("PermLive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_live"); + + b.Property("PermShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_shock"); + + b.Property("PermSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_sound"); + + b.Property("PermVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_vibrate"); + + b.HasKey("ShareRequest", "Shocker") + .HasName("share_requests_shockers_pkey"); + + b.HasIndex("Shocker"); + + b.ToTable("share_requests_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Device") + .HasColumnType("uuid") + .HasColumnName("device"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("Paused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("paused"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("Device") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledBy") + .HasColumnType("uuid") + .HasColumnName("controlled_by"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledBy"); + + b.HasIndex("ShockerId") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShare", b => + { + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("SharedWith") + .HasColumnType("uuid") + .HasColumnName("shared_with"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LimitDuration") + .HasColumnType("integer") + .HasColumnName("limit_duration"); + + b.Property("LimitIntensity") + .HasColumnType("smallint") + .HasColumnName("limit_intensity"); + + b.Property("Paused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("paused"); + + b.Property("PermLive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_live"); + + b.Property("PermShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_shock"); + + b.Property("PermSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_sound"); + + b.Property("PermVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_vibrate"); + + b.HasKey("ShockerId", "SharedWith") + .HasName("shocker_shares_pkey"); + + b.HasIndex("SharedWith") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("shocker_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LimitDuration") + .HasColumnType("integer") + .HasColumnName("limit_duration"); + + b.Property("LimitIntensity") + .HasColumnType("smallint") + .HasColumnName("limit_intensity"); + + b.Property("PermShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_shock"); + + b.Property("PermSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_sound"); + + b.Property("PermVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_vibrate"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerSharesLink", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("shocker_shares_links_pkey"); + + b.HasIndex("OwnerId") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("shocker_shares_links", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerSharesLinksShocker", b => + { + b.Property("ShareLinkId") + .HasColumnType("uuid") + .HasColumnName("share_link_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("LimitDuration") + .HasColumnType("integer") + .HasColumnName("limit_duration"); + + b.Property("LimitIntensity") + .HasColumnType("smallint") + .HasColumnName("limit_intensity"); + + b.Property("Paused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("paused"); + + b.Property("PermLive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("perm_live"); + + b.Property("PermShock") + .HasColumnType("boolean") + .HasColumnName("perm_shock"); + + b.Property("PermSound") + .HasColumnType("boolean") + .HasColumnName("perm_sound"); + + b.Property("PermVibrate") + .HasColumnType("boolean") + .HasColumnName("perm_vibrate"); + + b.HasKey("ShareLinkId", "ShockerId") + .HasName("shocker_shares_links_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_shares_links_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("EmailActivated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("email_activated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash"); + + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersActivation", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("secret"); + + b.Property("UsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_on"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("users_activation_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("users_activation", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("secret"); + + b.Property("UsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_on"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("users_email_change_pkey"); + + b.HasIndex("CreatedOn") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("UsedOn") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("UserId"); + + b.ToTable("users_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("users_name_changes_pkey"); + + b.HasIndex("CreatedOn") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("OldName") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.HasIndex("UserId") + .HasAnnotation("Npgsql:StorageParameter:deduplicate_items", "true"); + + b.ToTable("users_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "OwnerNavigation") + .WithMany("Devices") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("owner_user_id"); + + b.Navigation("OwnerNavigation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "DeviceNavigation") + .WithMany("DeviceOtaUpdates") + .HasForeignKey("Device") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("device_ota_updates_device"); + + b.Navigation("DeviceNavigation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShareRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "OwnerNavigation") + .WithMany("ShareRequestOwnerNavigations") + .HasForeignKey("Owner") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_share_requests_owner"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "UserNavigation") + .WithMany("ShareRequestUserNavigations") + .HasForeignKey("User") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_share_requests_user"); + + b.Navigation("OwnerNavigation"); + + b.Navigation("UserNavigation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShareRequestsShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.ShareRequest", "ShareRequestNavigation") + .WithMany("ShareRequestsShockers") + .HasForeignKey("ShareRequest") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_share_requests_shockers_share_request"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "ShockerNavigation") + .WithMany("ShareRequestsShockers") + .HasForeignKey("Shocker") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_share_requests_shockers_shocker"); + + b.Navigation("ShareRequestNavigation"); + + b.Navigation("ShockerNavigation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "DeviceNavigation") + .WithMany("Shockers") + .HasForeignKey("Device") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("device_id"); + + b.Navigation("DeviceNavigation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByNavigation") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledBy") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_controlled_by"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_id"); + + b.Navigation("ControlledByNavigation"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithNavigation") + .WithMany("ShockerShares") + .HasForeignKey("SharedWith") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("ref_shocker_id"); + + b.Navigation("SharedWithNavigation"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerSharesLink", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("ShockerSharesLinks") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerSharesLinksShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.ShockerSharesLink", "ShareLink") + .WithMany("ShockerSharesLinksShockers") + .HasForeignKey("ShareLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("share_link_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerSharesLinksShockers") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("shocker_id"); + + b.Navigation("ShareLink"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersActivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("UsersActivations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("UsersEmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UsersNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("UsersNameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("DeviceOtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShareRequest", b => + { + b.Navigation("ShareRequestsShockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("ShareRequestsShockers"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("ShockerShares"); + + b.Navigation("ShockerSharesLinksShockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerSharesLink", b => + { + b.Navigation("ShockerSharesLinksShockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("PasswordResets"); + + b.Navigation("ShareRequestOwnerNavigations"); + + b.Navigation("ShareRequestUserNavigations"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShares"); + + b.Navigation("ShockerSharesLinks"); + + b.Navigation("UsersActivations"); + + b.Navigation("UsersEmailChanges"); + + b.Navigation("UsersNameChanges"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20250203224107_RanksToRoles.cs b/Common/Migrations/20250203224107_RanksToRoles.cs new file mode 100644 index 00000000..11b30f5c --- /dev/null +++ b/Common/Migrations/20250203224107_RanksToRoles.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class RanksToRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + -- Drop the view temporarily to modify the underlying table + DROP VIEW admin_users_view; + + -- Add the roles column as a text array to replace the rank enum + ALTER TABLE users ADD roles text[] NOT NULL DEFAULT ARRAY[]::text[]; + + -- Migrate existing rank values into the roles array column + UPDATE users + SET roles = ( + CASE + WHEN rank = 'support' THEN ARRAY['support'] + WHEN rank = 'staff' THEN ARRAY['staff'] + WHEN rank = 'admin' THEN ARRAY['admin'] + WHEN rank = 'system' THEN ARRAY['system'] + ELSE ARRAY[]::text[] + END + ); + + -- Remove the rank column after migration + ALTER TABLE users DROP COLUMN rank; + + -- Replace the old rank_type enum with a new role_type enum, dropping 'user' in the process + DROP TYPE rank_type; + CREATE TYPE role_type AS ENUM ('support', 'staff', 'admin', 'system'); + + -- Update the roles column to use the new role_type enum array + ALTER TABLE users ALTER COLUMN roles SET DEFAULT ARRAY[]::role_type[]; + ALTER TABLE users ALTER COLUMN roles TYPE role_type[] USING CAST(roles as role_type[]); + + -- Recreate the admin_users_view to reflect the new roles structure + CREATE VIEW admin_users_view AS + SELECT + u.id, + u.name, + u.email, + SPLIT_PART(u.password_hash, ':', 1) AS password_hash_type, + u.created_at, + u.email_activated, + u.roles, + (SELECT COUNT(*) FROM api_tokens ato WHERE ato.user_id = u.id) AS api_token_count, + (SELECT COUNT(*) FROM password_resets pre WHERE pre.user_id = u.id) AS password_reset_count, + (SELECT COUNT(*) FROM shocker_shares ssh WHERE ssh.shared_with = u.id) AS shocker_share_count, + (SELECT COUNT(*) FROM shocker_shares_links ssl WHERE ssl.owner_id = u.id) AS shocker_share_link_count, + (SELECT COUNT(*) FROM users_email_changes uec WHERE uec.user_id = u.id) AS email_change_request_count, + (SELECT COUNT(*) FROM users_name_changes unc WHERE unc.user_id = u.id) AS name_change_request_count, + (SELECT COUNT(*) FROM users_activation uac WHERE uac.user_id = u.id) AS user_activation_count, + (SELECT COUNT(*) FROM devices dev WHERE dev.owner = u.id) AS device_count, + (SELECT COUNT(*) FROM devices dev JOIN shockers sck ON dev.id = sck.device WHERE dev.owner = u.id) AS shocker_count, + (SELECT COUNT(*) FROM devices dev JOIN shockers sck ON dev.id = sck.device JOIN shocker_control_logs scl ON scl.shocker_id = sck.id WHERE dev.owner = u.id) AS shocker_control_log_count + FROM + users u; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + -- Drop the view temporarily to modify the underlying table + DROP VIEW admin_users_view; + + -- Add the rank column back as a temporary nullable text column + ALTER TABLE users ADD rank text; + + -- Migrate roles array values back into a single rank value + UPDATE users + SET rank = ( + CASE + WHEN 'system' = ANY(roles) THEN 'system' + WHEN 'admin' = ANY(roles) THEN 'admin' + WHEN 'staff' = ANY(roles) THEN 'staff' + WHEN 'support' = ANY(roles) THEN 'support' + ELSE 'user' + END + ); + + -- Remove the roles column after migration + ALTER TABLE users DROP COLUMN roles; + + -- Restore the old rank_type enum + DROP TYPE role_type; + CREATE TYPE rank_type AS ENUM ('user', 'support', 'staff', 'admin', 'system'); + + -- Change the rank column back to a non-nullable rank_type enum + ALTER TABLE users ALTER COLUMN rank TYPE rank_type USING CAST(rank as rank_type); + ALTER TABLE users ALTER COLUMN rank SET NOT NULL; + + -- Recreate the admin_users_view to restore the original structure + CREATE VIEW admin_users_view AS + SELECT + u.id, + u.name, + u.email, + SPLIT_PART(u.password_hash, ':', 1) AS password_hash_type, + u.created_at, + u.email_activated, + u.rank, + (SELECT COUNT(*) FROM api_tokens ato WHERE ato.user_id = u.id) AS api_token_count, + (SELECT COUNT(*) FROM password_resets pre WHERE pre.user_id = u.id) AS password_reset_count, + (SELECT COUNT(*) FROM shocker_shares ssh WHERE ssh.shared_with = u.id) AS shocker_share_count, + (SELECT COUNT(*) FROM shocker_shares_links ssl WHERE ssl.owner_id = u.id) AS shocker_share_link_count, + (SELECT COUNT(*) FROM users_email_changes uec WHERE uec.user_id = u.id) AS email_change_request_count, + (SELECT COUNT(*) FROM users_name_changes unc WHERE unc.user_id = u.id) AS name_change_request_count, + (SELECT COUNT(*) FROM users_activation uac WHERE uac.user_id = u.id) AS user_activation_count, + (SELECT COUNT(*) FROM devices dev WHERE dev.owner = u.id) AS device_count, + (SELECT COUNT(*) FROM devices dev JOIN shockers sck ON dev.id = sck.device WHERE dev.owner = u.id) AS shocker_count, + (SELECT COUNT(*) FROM devices dev JOIN shockers sck ON dev.id = sck.device JOIN shocker_control_logs scl ON scl.shocker_id = sck.id WHERE dev.owner = u.id) AS shocker_control_log_count + FROM + users u; + """ + ); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 0f5f566a..258a7269 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -19,14 +19,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "9.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rank_type", new[] { "user", "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -79,9 +79,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("password_reset_count"); - b.Property("Rank") - .HasColumnType("rank_type") - .HasColumnName("rank"); + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); b.Property("ShockerControlLogCount") .HasColumnType("integer") @@ -705,9 +706,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)") .HasColumnName("password_hash"); - b.Property("Rank") - .HasColumnType("rank_type") - .HasColumnName("rank"); + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); b.HasKey("Id") .HasName("users_pkey"); diff --git a/Common/Models/RankType.cs b/Common/Models/RankType.cs deleted file mode 100644 index 46ad55d4..00000000 --- a/Common/Models/RankType.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OpenShock.Common.Models; - -public enum RankType -{ - User = 0, - Support = 1, - Staff = 2, - Admin = 3, - System = 4 -} \ No newline at end of file diff --git a/Common/Models/RankUtils.cs b/Common/Models/RankUtils.cs deleted file mode 100644 index 0dec10a8..00000000 --- a/Common/Models/RankUtils.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace OpenShock.Common.Models; - -public static class RankUtils -{ - public static bool IsAllowed(this RankType userRank, RankType rankNeeded) => userRank >= rankNeeded; -} \ No newline at end of file diff --git a/Common/Models/RoleType.cs b/Common/Models/RoleType.cs new file mode 100644 index 00000000..1cb64b97 --- /dev/null +++ b/Common/Models/RoleType.cs @@ -0,0 +1,9 @@ +namespace OpenShock.Common.Models; + +public enum RoleType +{ + Support, + Staff, + Admin, + System +} \ No newline at end of file diff --git a/Common/OpenShockDb/AdminUsersView.cs b/Common/OpenShockDb/AdminUsersView.cs index a14e8dd7..7fba6133 100644 --- a/Common/OpenShockDb/AdminUsersView.cs +++ b/Common/OpenShockDb/AdminUsersView.cs @@ -18,7 +18,7 @@ public class AdminUsersView public required bool EmailActivated { get; set; } - public required RankType Rank { get; set; } + public required List Roles { get; set; } public required int ApiTokenCount { get; set; } diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 3212bda5..fde75795 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -65,7 +65,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }) .HasPostgresEnum("password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }) .HasPostgresEnum("permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }) - .HasPostgresEnum("rank_type", new[] { "user", "support", "staff", "admin", "system" }) + .HasPostgresEnum("role_type", new[] { "support", "staff", "admin", "system" }) .HasPostgresEnum("shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" }) .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False"); @@ -465,9 +465,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.PasswordHash) .VarCharWithLength(HardLimits.PasswordHashMaxLength) .HasColumnName("password_hash"); - entity.Property(e => e.Rank) - .HasColumnType("rank_type") - .HasColumnName("rank"); + entity.Property(e => e.Roles) + .HasColumnType("role_type[]") + .HasColumnName("roles"); }); modelBuilder.Entity(entity => @@ -577,9 +577,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); entity.Property(e => e.EmailActivated) .HasColumnName("email_activated"); - entity.Property(e => e.Rank) - .HasColumnType("rank_type") - .HasColumnName("rank"); + entity.Property(e => e.Roles) + .HasColumnType("role_type[]") + .HasColumnName("roles") + .HasConversion(x => x.ToArray(), x => x.ToList()); entity.Property(e => e.ApiTokenCount) .HasColumnName("api_token_count"); entity.Property(e => e.PasswordResetCount) diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index a0036816..7146adac 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -18,7 +18,7 @@ public partial class User public bool EmailActivated { get; set; } - public RankType Rank { get; set; } + public List Roles { get; set; } = null!; public virtual ICollection ApiTokens { get; set; } = new List(); diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 447781f6..f341f440 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -29,15 +29,6 @@ namespace OpenShock.Common; public static class OpenShockServiceHelper { - // TODO: this is temporary while we still rely on enums for user ranks - static bool HandleRankCheck(AuthorizationHandlerContext context, RankType requiredRank) - { - var ranks = context.User.Identities.SelectMany(ident => ident.Claims.Where(claim => claim.Type == ident.RoleClaimType)).Select(claim => Enum.Parse(claim.Value)).ToArray(); - - // Has any rank that is higher than required rank - return ranks.Length != 0 && ranks.Max() >= requiredRank; - } - /// /// Register all OpenShock services for PRODUCTION use /// @@ -66,16 +57,12 @@ public static ServicesResult AddOpenShockServices(this IServiceCollection servic services.AddAuthorization(options => { - options.AddPolicy(OpenShockAuthPolicies.SystemAccess, policy => policy.RequireRole(RankType.System.ToString())); - options.AddPolicy(OpenShockAuthPolicies.AdminAccess, policy => policy.RequireAssertion(context => HandleRankCheck(context, RankType.Admin))); - options.AddPolicy(OpenShockAuthPolicies.StaffAccess, policy => policy.RequireAssertion(context => HandleRankCheck(context, RankType.Staff))); - options.AddPolicy(OpenShockAuthPolicies.SupportAccess, policy => policy.RequireAssertion(context => HandleRankCheck(context, RankType.Support))); - options.AddPolicy(OpenShockAuthPolicies.UserAccess, policy => policy.RequireAssertion(context => HandleRankCheck(context, RankType.User))); - - options.AddPolicy(OpenShockAuthPolicies.TokenSessionOnly, policy => policy.RequireClaim(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemas.ApiToken)); + options.AddPolicy(OpenShockAuthPolicies.RankAdmin, policy => policy.RequireRole("Admin", "System")); // TODO: Add token permission policies }); + services.AddSingleton(); + services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNameCaseInsensitive = true; @@ -182,7 +169,7 @@ public static ServicesResult AddOpenShockServices(this IServiceCollection servic { builder.UseNpgsql(config.Db.Conn, optionsBuilder => { - optionsBuilder.MapEnum(); + optionsBuilder.MapEnum(); optionsBuilder.MapEnum(); optionsBuilder.MapEnum(); optionsBuilder.MapEnum(); diff --git a/Common/Problems/CustomProblems/PolicyNotMetProblem.cs b/Common/Problems/CustomProblems/PolicyNotMetProblem.cs new file mode 100644 index 00000000..bc98e1ab --- /dev/null +++ b/Common/Problems/CustomProblems/PolicyNotMetProblem.cs @@ -0,0 +1,16 @@ +using System.Net; +using Microsoft.AspNetCore.Authorization; + +namespace OpenShock.Common.Problems.CustomProblems; + +public class PolicyNotMetProblem : OpenShockProblem +{ + public PolicyNotMetProblem(IEnumerable failedRequirements) : base( + "Authorization.Policy.NotMet", + "One or multiple policies were not met", HttpStatusCode.Forbidden, string.Empty) + { + FailedRequirements = failedRequirements; + } + + public IEnumerable FailedRequirements { get; set; } +} \ No newline at end of file diff --git a/Cron/DashboardAdminAuth.cs b/Cron/DashboardAdminAuth.cs index 03e8d134..adde7f67 100644 --- a/Cron/DashboardAdminAuth.cs +++ b/Cron/DashboardAdminAuth.cs @@ -1,5 +1,6 @@ using Hangfire.Dashboard; using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; @@ -37,6 +38,6 @@ private static async Task SessionAuthAdmin(string sessionKey, IRedisCollec var session = await loginSessions.FindByIdAsync(sessionKey); if (session == null) return false; var retrievedUser = await db.Users.FirstAsync(user => user.Id == session.UserId); - return retrievedUser.Rank == RankType.Admin; + return retrievedUser.IsRole(RoleType.Admin); } } \ No newline at end of file