From 8ded24b6f5d15d63492d88b75dda60656910f798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 17:43:47 +0200 Subject: [PATCH 01/14] Add endpoint for getting role assignments (only for Tournament atm) --- .../GetRoleAssignmentsEndpoint.cs | 83 +++++++++++++++++++ .../Tournaments/GetTournamentEndpoint.cs | 2 +- .../Helpers/RbacScopeHelper.cs | 35 ++++++++ .../Rules/RoleAssignmentMappingRule.cs | 47 +++++++++++ src/Turnierplan.App/Models/PrincipalDto.cs | 10 +++ .../Models/RoleAssignmentDto.cs | 22 +++++ src/Turnierplan.Core/ApiKey/ApiKey.cs | 2 +- src/Turnierplan.Core/Folder/Folder.cs | 2 +- src/Turnierplan.Core/Image/Image.cs | 2 +- .../Organization/Organization.cs | 2 +- .../RoleAssignment/RoleAssignment.cs | 2 +- .../SeedWork/IEntityWithRoleAssignments.cs | 2 +- .../Tournament/ITournamentRepository.cs | 5 +- src/Turnierplan.Core/Tournament/Tournament.cs | 2 +- src/Turnierplan.Core/Venue/Venue.cs | 2 +- .../Repositories/TournamentRepository.cs | 6 +- 16 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs create mode 100644 src/Turnierplan.App/Helpers/RbacScopeHelper.cs create mode 100644 src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs create mode 100644 src/Turnierplan.App/Models/PrincipalDto.cs create mode 100644 src/Turnierplan.App/Models/RoleAssignmentDto.cs diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs new file mode 100644 index 00000000..5ae61f48 --- /dev/null +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Helpers; +using Turnierplan.App.Mapping; +using Turnierplan.App.Models; +using Turnierplan.App.Security; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.Venue; + +namespace Turnierplan.App.Endpoints.RoleAssignments; + +internal sealed class GetRoleAssignmentsEndpoint : EndpointBase> +{ + protected override HttpMethod Method => HttpMethod.Get; + + protected override string Route => "/api/role-assignments"; + + protected override Delegate Handler => Handle; + + private static async Task Handle( + [FromQuery] string scope, + IApiKeyRepository apiKeyRepository, + IFolderRepository folderRepository, + IImageRepository imageRepository, + IOrganizationRepository organizationRepository, + ITournamentRepository tournamentRepository, + IVenueRepository venueRepository, + IAccessValidator accessValidator, + IMapper mapper) + { + if (!RbacScopeHelper.TryParseScopeId(scope, out var typeName, out var targetId)) + { + return Results.BadRequest("Invalid scope ID provided."); + } + + if (typeName.Equals("Turnierplan.Core.Tournament.Tournament")) + { + var tournament = await tournamentRepository.GetByPublicIdAsync(targetId).ConfigureAwait(false); + + if (tournament is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(tournament, Actions.ReadOrWriteRoleAssignments)) + { + return Results.Forbid(); + } + + var result = new List(); + + result.AddRange(mapper.MapCollection(tournament.RoleAssignments)); + + var organizationScopeId = tournament.Organization.GetScopeId(); + result.AddRange(mapper.MapCollection(tournament.Organization.RoleAssignments) + .Select(r => + r with + { + IsInherited = true, + InheritedFrom = organizationScopeId + })); + + if (tournament.Folder is not null) + { + var folderScopeId = tournament.Folder.GetScopeId(); + result.AddRange(mapper.MapCollection(tournament.Folder.RoleAssignments) + .Select(r => + r with + { + IsInherited = true, + InheritedFrom = folderScopeId + })); + } + + return Results.Ok(result); + } + + return Results.StatusCode(501); + } +} diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs index 938bcc0a..7b10f0e7 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs @@ -23,7 +23,7 @@ private static async Task Handle( IAccessValidator accessValidator, IMapper mapper) { - var tournament = await repository.GetByPublicIdAsync(id, ITournamentRepository.Include.GameRelevant | ITournamentRepository.Include.Venue | ITournamentRepository.Include.Folder).ConfigureAwait(false); + var tournament = await repository.GetByPublicIdAsync(id, ITournamentRepository.Include.GameRelevant | ITournamentRepository.Include.Venue).ConfigureAwait(false); if (tournament is null) { diff --git a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs new file mode 100644 index 00000000..bd45dcf2 --- /dev/null +++ b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Turnierplan.Core.PublicId; +using Turnierplan.Core.SeedWork; + +namespace Turnierplan.App.Helpers; + +internal static partial class RbacScopeHelper +{ + public static string GetScopeId(this T entity) + where T : Entity, IEntityWithRoleAssignments + { + return $"{typeof(T).FullName}/{entity.PublicId.ToString()}"; + } + + public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out string? objectTypeName, out PublicId targetObjectId) + { + var match = ScopeIdRegex().Match(scopeId); + + if (!match.Success || !PublicId.TryParse(match.Groups["ObjectId"].Value, out targetObjectId)) + { + objectTypeName = null; + targetObjectId = PublicId.Empty; + + return false; + } + + objectTypeName = match.Groups["FullTypeName"].Value; + + return true; + } + + [GeneratedRegex(@"^(?Turnierplan\.Core\.(?\w+)\.\k)/(?[A-Za-z0-9_-]{11})$")] + private static partial Regex ScopeIdRegex(); +} diff --git a/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs new file mode 100644 index 00000000..76ba9e83 --- /dev/null +++ b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs @@ -0,0 +1,47 @@ +using Turnierplan.App.Helpers; +using Turnierplan.App.Models; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.SeedWork; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.Venue; + +namespace Turnierplan.App.Mapping.Rules; + +internal abstract class RoleAssignmentMappingRuleBase : MappingRuleBase, RoleAssignmentDto> + where T : Entity, IEntityWithRoleAssignments +{ + protected override RoleAssignmentDto Map(IMapper mapper, MappingContext context, RoleAssignment source) + { + return new RoleAssignmentDto + { + Id = source.Id, + Scope = source.Scope.GetScopeId(), + CreatedAt = source.CreatedAt, + Role = source.Role, + Principal = new PrincipalDto + { + Kind = source.Principal.Kind, + ObjectId = source.Principal.ObjectId + }, + Description = source.Description, + IsInherited = false, + InheritedFrom = null + }; + } +} + +internal sealed class ApiKeyRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; + +internal sealed class FolderRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; + +internal sealed class ImageRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; + +internal sealed class OrganizationRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; + +internal sealed class TournamentRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; + +internal sealed class VenueRoleAssignmentMappingRule : RoleAssignmentMappingRuleBase; diff --git a/src/Turnierplan.App/Models/PrincipalDto.cs b/src/Turnierplan.App/Models/PrincipalDto.cs new file mode 100644 index 00000000..0cdc2f42 --- /dev/null +++ b/src/Turnierplan.App/Models/PrincipalDto.cs @@ -0,0 +1,10 @@ +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.App.Models; + +public sealed record PrincipalDto +{ + public required PrincipalKind Kind { get; init; } + + public required string ObjectId { get; init; } +} diff --git a/src/Turnierplan.App/Models/RoleAssignmentDto.cs b/src/Turnierplan.App/Models/RoleAssignmentDto.cs new file mode 100644 index 00000000..bc3babcb --- /dev/null +++ b/src/Turnierplan.App/Models/RoleAssignmentDto.cs @@ -0,0 +1,22 @@ +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.App.Models; + +public sealed record RoleAssignmentDto +{ + public required long Id { get; init; } + + public required string Scope { get; init; } + + public required DateTime CreatedAt { get; init; } + + public required Role Role { get; init; } + + public required PrincipalDto Principal { get; init; } + + public required string Description { get; init; } + + public required bool IsInherited { get; init; } + + public required string? InheritedFrom { get; init; } +} diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index 29b7a21c..a0163238 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.ApiKey; -public sealed class ApiKey : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class ApiKey : Entity, IEntityWithRoleAssignments { internal readonly List> _roleAssignments = new(); internal readonly List _requests = new(); diff --git a/src/Turnierplan.Core/Folder/Folder.cs b/src/Turnierplan.Core/Folder/Folder.cs index 04f68a63..d8ae7763 100644 --- a/src/Turnierplan.Core/Folder/Folder.cs +++ b/src/Turnierplan.Core/Folder/Folder.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.Folder; -public sealed class Folder : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class Folder : Entity, IEntityWithRoleAssignments { internal readonly List> _roleAssignments = new(); internal readonly List _tournaments = new(); diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index d2578226..baeeeade 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -4,7 +4,7 @@ namespace Turnierplan.Core.Image; -public sealed class Image : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class Image : Entity, IEntityWithRoleAssignments { internal readonly List> _roleAssignments = new(); diff --git a/src/Turnierplan.Core/Organization/Organization.cs b/src/Turnierplan.Core/Organization/Organization.cs index 722cae13..3bf20db6 100644 --- a/src/Turnierplan.Core/Organization/Organization.cs +++ b/src/Turnierplan.Core/Organization/Organization.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.Organization; -public sealed class Organization : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class Organization : Entity, IEntityWithRoleAssignments { internal readonly List> _roleAssignments = new(); internal readonly List _apiKeys = new(); diff --git a/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs index 75cdd1cf..df624202 100644 --- a/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs +++ b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs @@ -24,7 +24,7 @@ internal RoleAssignment(long id, DateTime createdAt, Role role, Principal princi Description = description; } - public override long Id { get; protected set; } + public override long Id { get; protected set; } // TODO: Replace with GUID public T Scope { get; internal set; } = null!; diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs index 640e89f3..92e60677 100644 --- a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs +++ b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs @@ -2,7 +2,7 @@ namespace Turnierplan.Core.SeedWork; -public interface IEntityWithRoleAssignments +public interface IEntityWithRoleAssignments : IEntityWithPublicId where T : Entity, IEntityWithRoleAssignments { IReadOnlyList> RoleAssignments { get; } diff --git a/src/Turnierplan.Core/Tournament/ITournamentRepository.cs b/src/Turnierplan.Core/Tournament/ITournamentRepository.cs index d651d6e8..fa8a1db1 100644 --- a/src/Turnierplan.Core/Tournament/ITournamentRepository.cs +++ b/src/Turnierplan.Core/Tournament/ITournamentRepository.cs @@ -15,9 +15,8 @@ public enum Include Matches = 4, Documents = 8, Venue = 16, - Folder = 32, - FolderWithTournaments = 64, - Images = 128, + FolderWithTournaments = 32, + Images = 64, GameRelevant = Teams | Groups | Matches } diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 135033a5..bbdaa990 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -9,7 +9,7 @@ namespace Turnierplan.Core.Tournament; -public sealed class Tournament : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class Tournament : Entity, IEntityWithRoleAssignments { internal readonly GroupParticipantComparer _groupParticipantComparer; internal int? _nextEntityId; diff --git a/src/Turnierplan.Core/Venue/Venue.cs b/src/Turnierplan.Core/Venue/Venue.cs index 1de0f9ba..d2218c7d 100644 --- a/src/Turnierplan.Core/Venue/Venue.cs +++ b/src/Turnierplan.Core/Venue/Venue.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.Venue; -public sealed class Venue : Entity, IEntityWithPublicId, IEntityWithRoleAssignments +public sealed class Venue : Entity, IEntityWithRoleAssignments { internal readonly List> _roleAssignments = new(); internal readonly List _tournaments = new(); diff --git a/src/Turnierplan.Dal/Repositories/TournamentRepository.cs b/src/Turnierplan.Dal/Repositories/TournamentRepository.cs index 3ba7f40e..302fbbee 100644 --- a/src/Turnierplan.Dal/Repositories/TournamentRepository.cs +++ b/src/Turnierplan.Dal/Repositories/TournamentRepository.cs @@ -48,11 +48,6 @@ internal sealed class TournamentRepository(TurnierplanContext context) : Reposit if (include.HasFlag(ITournamentRepository.Include.FolderWithTournaments)) { query = query.Include(x => x.Folder).ThenInclude(x => x!.Tournaments); - query = query.Include(x => x.Folder).ThenInclude(x => x!.RoleAssignments); - } - else if (include.HasFlag(ITournamentRepository.Include.Folder)) - { - query = query.Include(x => x.Folder).ThenInclude(x => x!.RoleAssignments); } if (include.HasFlag(ITournamentRepository.Include.Images)) @@ -63,6 +58,7 @@ internal sealed class TournamentRepository(TurnierplanContext context) : Reposit } query = query.Include(x => x.Organization).ThenInclude(x => x.RoleAssignments); + query = query.Include(x => x.Folder).ThenInclude(x => x!.RoleAssignments); query = query.Include(x => x.RoleAssignments); query = query.AsSplitQuery(); From 28bbea884840bd510aa4969fd3501ea68f2746a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 17:46:13 +0200 Subject: [PATCH 02/14] Use GUID for role assignment id --- .../Models/RoleAssignmentDto.cs | 2 +- .../RoleAssignment/RoleAssignment.cs | 8 ++-- ...cs => 20250615154535_Add_RBAC.Designer.cs} | 38 +++++++------------ ...Add_RBAC.cs => 20250615154535_Add_RBAC.cs} | 19 +++------- .../TurnierplanContextModelSnapshot.cs | 36 ++++++------------ 5 files changed, 36 insertions(+), 67 deletions(-) rename src/Turnierplan.Dal/Migrations/{20250615124413_Add_RBAC.Designer.cs => 20250615154535_Add_RBAC.Designer.cs} (97%) rename src/Turnierplan.Dal/Migrations/{20250615124413_Add_RBAC.cs => 20250615154535_Add_RBAC.cs} (92%) diff --git a/src/Turnierplan.App/Models/RoleAssignmentDto.cs b/src/Turnierplan.App/Models/RoleAssignmentDto.cs index bc3babcb..2d728b24 100644 --- a/src/Turnierplan.App/Models/RoleAssignmentDto.cs +++ b/src/Turnierplan.App/Models/RoleAssignmentDto.cs @@ -4,7 +4,7 @@ namespace Turnierplan.App.Models; public sealed record RoleAssignmentDto { - public required long Id { get; init; } + public required Guid Id { get; init; } public required string Scope { get; init; } diff --git a/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs index df624202..d1dec9b0 100644 --- a/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs +++ b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs @@ -2,12 +2,12 @@ namespace Turnierplan.Core.RoleAssignment; -public sealed class RoleAssignment : Entity +public sealed class RoleAssignment : Entity where T : Entity, IEntityWithRoleAssignments { internal RoleAssignment(T scope, Role role, Principal principal, string? description = null) { - Id = 0; + Id = Guid.NewGuid(); Scope = scope; CreatedAt = DateTime.UtcNow; Role = role; @@ -15,7 +15,7 @@ internal RoleAssignment(T scope, Role role, Principal principal, string? descrip Description = description ?? string.Empty; } - internal RoleAssignment(long id, DateTime createdAt, Role role, Principal principal, string description) + internal RoleAssignment(Guid id, DateTime createdAt, Role role, Principal principal, string description) { Id = id; CreatedAt = createdAt; @@ -24,7 +24,7 @@ internal RoleAssignment(long id, DateTime createdAt, Role role, Principal princi Description = description; } - public override long Id { get; protected set; } // TODO: Replace with GUID + public override Guid Id { get; protected set; } public T Scope { get; internal set; } = null!; diff --git a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.Designer.cs similarity index 97% rename from src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs rename to src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.Designer.cs index cb307f87..24a40f37 100644 --- a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs +++ b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.Designer.cs @@ -13,7 +13,7 @@ namespace Turnierplan.Dal.Migrations { [DbContext(typeof(TurnierplanContext))] - [Migration("20250615124413_Add_RBAC")] + [Migration("20250615154535_Add_RBAC")] partial class Add_RBAC { /// @@ -266,11 +266,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("ApiKeyId") .HasColumnType("bigint"); @@ -299,11 +297,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -332,11 +328,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -365,11 +359,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -398,11 +390,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -431,11 +421,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); diff --git a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs similarity index 92% rename from src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs rename to src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs index ba987f69..d6800797 100644 --- a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs +++ b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs @@ -1,6 +1,5 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -46,8 +45,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), ApiKeyId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), @@ -71,8 +69,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), FolderId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), @@ -96,8 +93,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), ImageId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), @@ -121,8 +117,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), OrganizationId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), @@ -146,8 +141,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), TournamentId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), @@ -171,8 +165,7 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", columns: table => new { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Id = table.Column(type: "uuid", nullable: false), VenueId = table.Column(type: "bigint", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), Role = table.Column(type: "integer", nullable: false), diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index c975cf02..0b302773 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -263,11 +263,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("ApiKeyId") .HasColumnType("bigint"); @@ -296,11 +294,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -329,11 +325,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -362,11 +356,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -395,11 +387,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -428,11 +418,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + .HasColumnType("uuid"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); From 0b26b8c365232636d323ae7a635d9e8b394c1b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 17:50:56 +0200 Subject: [PATCH 03/14] Change to scope ID format --- .../GetRoleAssignmentsEndpoint.cs | 18 +++--------------- src/Turnierplan.App/Helpers/RbacScopeHelper.cs | 6 +++--- .../Mapping/Rules/RoleAssignmentMappingRule.cs | 3 +-- .../Models/RoleAssignmentDto.cs | 2 -- 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs index 5ae61f48..14ed3803 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.BadRequest("Invalid scope ID provided."); } - if (typeName.Equals("Turnierplan.Core.Tournament.Tournament")) + if (typeName.Equals("Tournament")) { var tournament = await tournamentRepository.GetByPublicIdAsync(targetId).ConfigureAwait(false); @@ -54,25 +54,13 @@ private static async Task Handle( result.AddRange(mapper.MapCollection(tournament.RoleAssignments)); - var organizationScopeId = tournament.Organization.GetScopeId(); result.AddRange(mapper.MapCollection(tournament.Organization.RoleAssignments) - .Select(r => - r with - { - IsInherited = true, - InheritedFrom = organizationScopeId - })); + .Select(r => r with { IsInherited = true })); if (tournament.Folder is not null) { - var folderScopeId = tournament.Folder.GetScopeId(); result.AddRange(mapper.MapCollection(tournament.Folder.RoleAssignments) - .Select(r => - r with - { - IsInherited = true, - InheritedFrom = folderScopeId - })); + .Select(r => r with { IsInherited = true })); } return Results.Ok(result); diff --git a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs index bd45dcf2..0ef0217c 100644 --- a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs +++ b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs @@ -10,7 +10,7 @@ internal static partial class RbacScopeHelper public static string GetScopeId(this T entity) where T : Entity, IEntityWithRoleAssignments { - return $"{typeof(T).FullName}/{entity.PublicId.ToString()}"; + return $"{typeof(T).Name}/{entity.PublicId.ToString()}"; } public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out string? objectTypeName, out PublicId targetObjectId) @@ -25,11 +25,11 @@ public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out strin return false; } - objectTypeName = match.Groups["FullTypeName"].Value; + objectTypeName = match.Groups["TypeName"].Value; return true; } - [GeneratedRegex(@"^(?Turnierplan\.Core\.(?\w+)\.\k)/(?[A-Za-z0-9_-]{11})$")] + [GeneratedRegex(@"^(?\w+)/(?[A-Za-z0-9_-]{11})$")] private static partial Regex ScopeIdRegex(); } diff --git a/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs index 76ba9e83..c491bf89 100644 --- a/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/RoleAssignmentMappingRule.cs @@ -28,8 +28,7 @@ protected override RoleAssignmentDto Map(IMapper mapper, MappingContext context, ObjectId = source.Principal.ObjectId }, Description = source.Description, - IsInherited = false, - InheritedFrom = null + IsInherited = false }; } } diff --git a/src/Turnierplan.App/Models/RoleAssignmentDto.cs b/src/Turnierplan.App/Models/RoleAssignmentDto.cs index 2d728b24..a1bcffe0 100644 --- a/src/Turnierplan.App/Models/RoleAssignmentDto.cs +++ b/src/Turnierplan.App/Models/RoleAssignmentDto.cs @@ -17,6 +17,4 @@ public sealed record RoleAssignmentDto public required string Description { get; init; } public required bool IsInherited { get; init; } - - public required string? InheritedFrom { get; init; } } From 0b493605e8368dc49e3ca70e4902c046220f201c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 17:53:12 +0200 Subject: [PATCH 04/14] Add RbacScopeId property --- src/Turnierplan.App/Mapping/Rules/ApiKeyMappingRule.cs | 2 ++ src/Turnierplan.App/Mapping/Rules/FolderMappingRule.cs | 2 ++ src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs | 2 ++ src/Turnierplan.App/Mapping/Rules/OrganizationMappingRule.cs | 2 ++ src/Turnierplan.App/Mapping/Rules/TournamentMappingRule.cs | 2 ++ src/Turnierplan.App/Mapping/Rules/VenueMappingRule.cs | 2 ++ src/Turnierplan.App/Models/ApiKeyDto.cs | 2 ++ src/Turnierplan.App/Models/FolderDto.cs | 2 ++ src/Turnierplan.App/Models/ImageDto.cs | 2 ++ src/Turnierplan.App/Models/OrganizationDto.cs | 2 ++ src/Turnierplan.App/Models/TournamentDto.cs | 2 ++ src/Turnierplan.App/Models/VenueDto.cs | 2 ++ 12 files changed, 24 insertions(+) diff --git a/src/Turnierplan.App/Mapping/Rules/ApiKeyMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/ApiKeyMappingRule.cs index cc8c0399..1e2cbc5c 100644 --- a/src/Turnierplan.App/Mapping/Rules/ApiKeyMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/ApiKeyMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.Core.ApiKey; @@ -10,6 +11,7 @@ protected override ApiKeyDto Map(IMapper mapper, MappingContext context, ApiKey return new ApiKeyDto { Id = source.PublicId, + RbacScopeId = source.GetScopeId(), Name = source.Name, Description = source.Description, Secret = null, diff --git a/src/Turnierplan.App/Mapping/Rules/FolderMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/FolderMappingRule.cs index 63634dfa..42e188d8 100644 --- a/src/Turnierplan.App/Mapping/Rules/FolderMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/FolderMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.Core.Folder; @@ -11,6 +12,7 @@ protected override FolderDto Map(IMapper mapper, MappingContext context, Folder { Id = source.PublicId, OrganizationId = source.Organization.PublicId, + RbacScopeId = source.GetScopeId(), Name = source.Name }; } diff --git a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs index 2506e024..b04d43e9 100644 --- a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.Core.Image; using Turnierplan.ImageStorage; @@ -18,6 +19,7 @@ protected override ImageDto Map(IMapper mapper, MappingContext context, Image so return new ImageDto { Id = source.PublicId, + RbacScopeId = source.GetScopeId(), CreatedAt = source.CreatedAt, Name = source.Name, Url = _imageStorage.GetFullImageUrl(source), diff --git a/src/Turnierplan.App/Mapping/Rules/OrganizationMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/OrganizationMappingRule.cs index 02c54607..b5b796c0 100644 --- a/src/Turnierplan.App/Mapping/Rules/OrganizationMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/OrganizationMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.Core.Organization; @@ -10,6 +11,7 @@ protected override OrganizationDto Map(IMapper mapper, MappingContext context, O return new OrganizationDto { Id = source.PublicId, + RbacScopeId = source.GetScopeId(), Name = source.Name }; } diff --git a/src/Turnierplan.App/Mapping/Rules/TournamentMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/TournamentMappingRule.cs index 18058715..5fc599d6 100644 --- a/src/Turnierplan.App/Mapping/Rules/TournamentMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/TournamentMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.App.Models.Enums; using Turnierplan.Core.Tournament; @@ -14,6 +15,7 @@ protected override TournamentDto Map(IMapper mapper, MappingContext context, Tou { Id = source.PublicId, OrganizationId = source.Organization.PublicId, + RbacScopeId = source.GetScopeId(), FolderId = source.Folder?.PublicId, VenueId = source.Venue?.PublicId, Name = source.Name, diff --git a/src/Turnierplan.App/Mapping/Rules/VenueMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/VenueMappingRule.cs index 82dbff64..3f150c96 100644 --- a/src/Turnierplan.App/Mapping/Rules/VenueMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/VenueMappingRule.cs @@ -1,3 +1,4 @@ +using Turnierplan.App.Helpers; using Turnierplan.App.Models; using Turnierplan.Core.Venue; @@ -11,6 +12,7 @@ protected override VenueDto Map(IMapper mapper, MappingContext context, Venue so { Id = source.PublicId, OrganizationId = source.Organization.PublicId, + RbacScopeId = source.GetScopeId(), Name = source.Name, Description = source.Description, AddressDetails = source.AddressDetails.ToArray(), diff --git a/src/Turnierplan.App/Models/ApiKeyDto.cs b/src/Turnierplan.App/Models/ApiKeyDto.cs index 0c090996..e29b16f4 100644 --- a/src/Turnierplan.App/Models/ApiKeyDto.cs +++ b/src/Turnierplan.App/Models/ApiKeyDto.cs @@ -6,6 +6,8 @@ public sealed record ApiKeyDto { public required PublicId Id { get; init; } + public required string RbacScopeId { get; init; } + public required string Name { get; init; } public required string Description { get; init; } diff --git a/src/Turnierplan.App/Models/FolderDto.cs b/src/Turnierplan.App/Models/FolderDto.cs index ebaf95cb..1d84ef87 100644 --- a/src/Turnierplan.App/Models/FolderDto.cs +++ b/src/Turnierplan.App/Models/FolderDto.cs @@ -8,5 +8,7 @@ public sealed record FolderDto public required PublicId OrganizationId { get; init; } + public required string RbacScopeId { get; init; } + public required string Name { get; init; } } diff --git a/src/Turnierplan.App/Models/ImageDto.cs b/src/Turnierplan.App/Models/ImageDto.cs index 07ecaf31..dba8f82b 100644 --- a/src/Turnierplan.App/Models/ImageDto.cs +++ b/src/Turnierplan.App/Models/ImageDto.cs @@ -6,6 +6,8 @@ public sealed record ImageDto { public required PublicId Id { get; init; } + public required string RbacScopeId { get; init; } + public required DateTime CreatedAt { get; init; } public required string Name { get; init; } diff --git a/src/Turnierplan.App/Models/OrganizationDto.cs b/src/Turnierplan.App/Models/OrganizationDto.cs index ccf69187..7a4e1259 100644 --- a/src/Turnierplan.App/Models/OrganizationDto.cs +++ b/src/Turnierplan.App/Models/OrganizationDto.cs @@ -6,5 +6,7 @@ public sealed record OrganizationDto { public required PublicId Id { get; init; } + public required string RbacScopeId { get; init; } + public required string Name { get; init; } } diff --git a/src/Turnierplan.App/Models/TournamentDto.cs b/src/Turnierplan.App/Models/TournamentDto.cs index 95ed78da..efe6bf5c 100644 --- a/src/Turnierplan.App/Models/TournamentDto.cs +++ b/src/Turnierplan.App/Models/TournamentDto.cs @@ -9,6 +9,8 @@ public sealed record TournamentDto public required PublicId OrganizationId { get; init; } + public required string RbacScopeId { get; init; } + public PublicId? FolderId { get; init; } public PublicId? VenueId { get; init; } diff --git a/src/Turnierplan.App/Models/VenueDto.cs b/src/Turnierplan.App/Models/VenueDto.cs index d38ffa24..4eb4d75e 100644 --- a/src/Turnierplan.App/Models/VenueDto.cs +++ b/src/Turnierplan.App/Models/VenueDto.cs @@ -8,6 +8,8 @@ public sealed record VenueDto public required PublicId OrganizationId { get; init; } + public required string RbacScopeId { get; init; } + public required string Name { get; init; } public required string Description { get; init; } From 7afc51a6d64d882235b30749d788d660a451be0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:04:16 +0200 Subject: [PATCH 05/14] Implement GET endpoint for all object types --- .../GetRoleAssignmentsEndpoint.cs | 64 ++++++++++++------- src/Turnierplan.Core/ApiKey/ApiKey.cs | 2 +- src/Turnierplan.Core/Folder/Folder.cs | 2 +- src/Turnierplan.Core/Image/Image.cs | 2 +- .../SeedWork/IEntityWithOrganization.cs | 6 ++ src/Turnierplan.Core/Tournament/Tournament.cs | 2 +- src/Turnierplan.Core/Venue/Venue.cs | 2 +- 7 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 src/Turnierplan.Core/SeedWork/IEntityWithOrganization.cs diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs index 14ed3803..9df83e78 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs @@ -7,6 +7,8 @@ using Turnierplan.Core.Folder; using Turnierplan.Core.Image; using Turnierplan.Core.Organization; +using Turnierplan.Core.PublicId; +using Turnierplan.Core.SeedWork; using Turnierplan.Core.Tournament; using Turnierplan.Core.Venue; @@ -33,39 +35,57 @@ private static async Task Handle( { if (!RbacScopeHelper.TryParseScopeId(scope, out var typeName, out var targetId)) { - return Results.BadRequest("Invalid scope ID provided."); + return Results.BadRequest("Invalid scope identifier provided."); } - if (typeName.Equals("Tournament")) + var task = typeName switch { - var tournament = await tournamentRepository.GetByPublicIdAsync(targetId).ConfigureAwait(false); + "ApiKey" => GetRoleAssignmentsResultAsync(apiKeyRepository, targetId, accessValidator, mapper), + "Folder" => GetRoleAssignmentsResultAsync(folderRepository, targetId, accessValidator, mapper), + "Image" => GetRoleAssignmentsResultAsync(imageRepository, targetId, accessValidator, mapper), + "Organization" => GetRoleAssignmentsResultAsync(organizationRepository, targetId, accessValidator, mapper), + "Tournament" => GetRoleAssignmentsResultAsync(tournamentRepository, targetId, accessValidator, mapper), + "Venue" => GetRoleAssignmentsResultAsync(venueRepository, targetId, accessValidator, mapper), + _ => null + }; - if (tournament is null) - { - return Results.NotFound(); - } + return task is null + ? Results.BadRequest("Invalid scope identifier provided.") + : await task.ConfigureAwait(false); + } - if (!accessValidator.IsActionAllowed(tournament, Actions.ReadOrWriteRoleAssignments)) - { - return Results.Forbid(); - } + private static async Task GetRoleAssignmentsResultAsync(IRepositoryWithPublicId repository, PublicId targetId, IAccessValidator accessValidator, IMapper mapper) + where T : Entity, IEntityWithRoleAssignments + { + var entity = await repository.GetByPublicIdAsync(targetId).ConfigureAwait(false); - var result = new List(); + if (entity is null) + { + return Results.NotFound(); + } - result.AddRange(mapper.MapCollection(tournament.RoleAssignments)); + if (!accessValidator.IsActionAllowed(entity, Actions.ReadOrWriteRoleAssignments)) + { + return Results.Forbid(); + } - result.AddRange(mapper.MapCollection(tournament.Organization.RoleAssignments) - .Select(r => r with { IsInherited = true })); + var result = new List(); - if (tournament.Folder is not null) - { - result.AddRange(mapper.MapCollection(tournament.Folder.RoleAssignments) - .Select(r => r with { IsInherited = true })); - } + result.AddRange(mapper.MapCollection(entity.RoleAssignments)); - return Results.Ok(result); + if (entity is IEntityWithOrganization entityWithOrganization) + { + result.AddRange(mapper.MapCollection(entityWithOrganization.Organization.RoleAssignments) + .Select(r => r with { IsInherited = true })); + } + + // Special case in generic method is not the cleanest... + if (entity is Tournament { Folder: not null } tournament) + { + result.AddRange(mapper.MapCollection(tournament.Folder.RoleAssignments) + .Select(r => r with { IsInherited = true })); } - return Results.StatusCode(501); + return Results.Ok(result); } } diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index a0163238..db540b9f 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.ApiKey; -public sealed class ApiKey : Entity, IEntityWithRoleAssignments +public sealed class ApiKey : Entity, IEntityWithRoleAssignments, IEntityWithOrganization { internal readonly List> _roleAssignments = new(); internal readonly List _requests = new(); diff --git a/src/Turnierplan.Core/Folder/Folder.cs b/src/Turnierplan.Core/Folder/Folder.cs index d8ae7763..8cc9d827 100644 --- a/src/Turnierplan.Core/Folder/Folder.cs +++ b/src/Turnierplan.Core/Folder/Folder.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.Folder; -public sealed class Folder : Entity, IEntityWithRoleAssignments +public sealed class Folder : Entity, IEntityWithRoleAssignments, IEntityWithOrganization { internal readonly List> _roleAssignments = new(); internal readonly List _tournaments = new(); diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index baeeeade..9d48942d 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -4,7 +4,7 @@ namespace Turnierplan.Core.Image; -public sealed class Image : Entity, IEntityWithRoleAssignments +public sealed class Image : Entity, IEntityWithRoleAssignments, IEntityWithOrganization { internal readonly List> _roleAssignments = new(); diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithOrganization.cs b/src/Turnierplan.Core/SeedWork/IEntityWithOrganization.cs new file mode 100644 index 00000000..43e91f1e --- /dev/null +++ b/src/Turnierplan.Core/SeedWork/IEntityWithOrganization.cs @@ -0,0 +1,6 @@ +namespace Turnierplan.Core.SeedWork; + +public interface IEntityWithOrganization +{ + Organization.Organization Organization { get; } +} diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index bbdaa990..62d54140 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -9,7 +9,7 @@ namespace Turnierplan.Core.Tournament; -public sealed class Tournament : Entity, IEntityWithRoleAssignments +public sealed class Tournament : Entity, IEntityWithRoleAssignments, IEntityWithOrganization { internal readonly GroupParticipantComparer _groupParticipantComparer; internal int? _nextEntityId; diff --git a/src/Turnierplan.Core/Venue/Venue.cs b/src/Turnierplan.Core/Venue/Venue.cs index d2218c7d..e6a3eb85 100644 --- a/src/Turnierplan.Core/Venue/Venue.cs +++ b/src/Turnierplan.Core/Venue/Venue.cs @@ -3,7 +3,7 @@ namespace Turnierplan.Core.Venue; -public sealed class Venue : Entity, IEntityWithRoleAssignments +public sealed class Venue : Entity, IEntityWithRoleAssignments, IEntityWithOrganization { internal readonly List> _roleAssignments = new(); internal readonly List _tournaments = new(); From 25b28de8c43c2860285a88fc0a8ed60574af8410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:50:45 +0200 Subject: [PATCH 06/14] Add POST & DELETE endpoints --- .../CreateRoleAssignmentEndpoint.cs | 163 ++++++++++++++++++ .../DeleteRoleAssignmentEndpoint.cs | 94 ++++++++++ .../GetRoleAssignmentsEndpoint.cs | 16 +- .../Helpers/RbacScopeHelper.cs | 4 +- src/Turnierplan.Core/ApiKey/ApiKey.cs | 5 + src/Turnierplan.Core/Folder/Folder.cs | 5 + src/Turnierplan.Core/Image/Image.cs | 5 + .../Organization/Organization.cs | 5 + .../SeedWork/IEntityWithRoleAssignments.cs | 2 + src/Turnierplan.Core/Tournament/Tournament.cs | 5 + src/Turnierplan.Core/Venue/Venue.cs | 5 + 11 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs create mode 100644 src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs new file mode 100644 index 00000000..f78f843c --- /dev/null +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -0,0 +1,163 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Extensions; +using Turnierplan.App.Helpers; +using Turnierplan.App.Mapping; +using Turnierplan.App.Models; +using Turnierplan.App.Security; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Extensions; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.PublicId; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.SeedWork; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.User; +using Turnierplan.Core.Venue; +using Turnierplan.Dal; + +namespace Turnierplan.App.Endpoints.RoleAssignments; + +internal sealed class CreateRoleAssignmentEndpoint : EndpointBase +{ + protected override HttpMethod Method => HttpMethod.Post; + + protected override string Route => "/api/role-assignments"; + + protected override Delegate Handler => Handle; + + private static async Task Handle( + [FromBody] CreateRoleAssignmentEndpointRequest request, + IApiKeyRepository apiKeyRepository, + IFolderRepository folderRepository, + IImageRepository imageRepository, + IOrganizationRepository organizationRepository, + ITournamentRepository tournamentRepository, + IUserRepository userRepository, + IVenueRepository venueRepository, + IAccessValidator accessValidator, + IMapper mapper, + CancellationToken cancellationToken) + { + if (!Validator.Instance.ValidateAndGetResult(request, out var result)) + { + return result; + } + + if (!RbacScopeHelper.TryParseScopeId(request.ScopeId, out var typeName, out var targetId)) + { + return Results.BadRequest("Invalid scope identifier provided."); + } + + var task = typeName switch + { + "ApiKey" => CreateRoleAssignmentAsync(request, apiKeyRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "Folder" => CreateRoleAssignmentAsync(request, folderRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "Image" => CreateRoleAssignmentAsync(request, imageRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "Organization" => CreateRoleAssignmentAsync(request, organizationRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "Tournament" => CreateRoleAssignmentAsync(request, tournamentRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "Venue" => CreateRoleAssignmentAsync(request, venueRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + _ => null + }; + + return task is null + ? Results.BadRequest("Invalid scope identifier provided.") + : await task.ConfigureAwait(false); + } + + private static async Task CreateRoleAssignmentAsync( + CreateRoleAssignmentEndpointRequest request, + IRepositoryWithPublicId repository, + PublicId targetId, + IAccessValidator accessValidator, + IApiKeyRepository apiKeyRepository, + IUserRepository userRepository, + IMapper mapper, + CancellationToken cancellationToken) + where T : Entity, IEntityWithRoleAssignments + { + var entity = await repository.GetByPublicIdAsync(targetId).ConfigureAwait(false); + + if (entity is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(entity, Actions.ReadOrWriteRoleAssignments)) + { + return Results.Forbid(); + } + + var principal = await GetPrincipalAsync(request, apiKeyRepository, userRepository).ConfigureAwait(false); + + if (principal is null) + { + return Results.BadRequest("Could not determine principal based on the provided information."); + } + + var roleAssignment = entity.AddRoleAssignment(request.Role, principal, request.Description); + + await repository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return Results.Ok(mapper.Map(roleAssignment)); + } + + private static async Task GetPrincipalAsync( + CreateRoleAssignmentEndpointRequest request, + IApiKeyRepository apiKeyRepository, + IUserRepository userRepository) + { + if (request.ApiKeyId.HasValue) + { + var apiKey = await apiKeyRepository.GetByPublicIdAsync(request.ApiKeyId.Value).ConfigureAwait(false); + + return apiKey?.AsPrincipal(); + } + + if (request.UserEmail is not null) + { + var user = await userRepository.GetByEmailAsync(request.UserEmail).ConfigureAwait(false); + + return user?.AsPrincipal(); + } + + throw new InvalidOperationException("Invalid request object provided."); + } + + public sealed record CreateRoleAssignmentEndpointRequest + { + public required string ScopeId { get; init; } + + public required Role Role { get; init; } + + public required PublicId? ApiKeyId { get; init; } + + public required string? UserEmail { get; init; } + + public required string Description { get; init; } + } + + private sealed class Validator : AbstractValidator + { + public static readonly Validator Instance = new(); + + private Validator() + { + RuleFor(x => x.ScopeId) + .Matches(RbacScopeHelper.ScopeIdRegex()); + + RuleFor(x => x.Role) + .IsInEnum(); + + RuleFor(x => x) + .Must(x => x.ApiKeyId is null ^ x.UserEmail is null) + .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserEmail)} must be specified."); + + RuleFor(x => x.Description) + .NotEmpty() + .MaximumLength(ValidationConstants.RoleAssignment.MaxDescriptionLength); + } + } +} diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs new file mode 100644 index 00000000..ad893579 --- /dev/null +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Helpers; +using Turnierplan.App.Security; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.PublicId; +using Turnierplan.Core.SeedWork; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.Venue; + +namespace Turnierplan.App.Endpoints.RoleAssignments; + +internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase +{ + protected override HttpMethod Method => HttpMethod.Delete; + + protected override string Route => "/api/organizations/{scopeId}/{roleAssignmentId}"; + + protected override Delegate Handler => Handle; + + private static async Task Handle( + [FromRoute] string scopeId, + [FromRoute] string roleAssignmentId, + IApiKeyRepository apiKeyRepository, + IFolderRepository folderRepository, + IImageRepository imageRepository, + IOrganizationRepository organizationRepository, + ITournamentRepository tournamentRepository, + IVenueRepository venueRepository, + IAccessValidator accessValidator, + CancellationToken cancellationToken) + { + if (!RbacScopeHelper.TryParseScopeId(scopeId, out var typeName, out var targetId)) + { + return Results.BadRequest("Invalid scope identifier provided."); + } + + if (!Guid.TryParse(roleAssignmentId, out var roleAssignmentGuid)) + { + return Results.BadRequest("Invalid role assignment provided."); + } + + var task = typeName switch + { + "ApiKey" => DeleteRoleAssignmentAsync(apiKeyRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + "Folder" => DeleteRoleAssignmentAsync(folderRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + "Image" => DeleteRoleAssignmentAsync(imageRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + "Organization" => DeleteRoleAssignmentAsync(organizationRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + "Tournament" => DeleteRoleAssignmentAsync(tournamentRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + "Venue" => DeleteRoleAssignmentAsync(venueRepository, targetId, accessValidator, roleAssignmentGuid, cancellationToken), + _ => null + }; + + return task is null + ? Results.BadRequest("Invalid scope identifier provided.") + : await task.ConfigureAwait(false); + } + + private static async Task DeleteRoleAssignmentAsync( + IRepositoryWithPublicId repository, + PublicId targetId, + IAccessValidator accessValidator, + Guid roleAssignmentId, + CancellationToken cancellationToken) + where T : Entity, IEntityWithRoleAssignments + { + var entity = await repository.GetByPublicIdAsync(targetId).ConfigureAwait(false); + + if (entity is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(entity, Actions.ReadOrWriteRoleAssignments)) + { + return Results.Forbid(); + } + + var roleAssignment = entity.RoleAssignments.FirstOrDefault(x => x.Id == roleAssignmentId); + + if (roleAssignment is null) + { + return Results.NotFound(); + } + + entity.RemoveRoleAssignment(roleAssignment); + + await repository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return Results.NoContent(); + } +} diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs index 9df83e78..778680a9 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs @@ -40,21 +40,21 @@ private static async Task Handle( var task = typeName switch { - "ApiKey" => GetRoleAssignmentsResultAsync(apiKeyRepository, targetId, accessValidator, mapper), - "Folder" => GetRoleAssignmentsResultAsync(folderRepository, targetId, accessValidator, mapper), - "Image" => GetRoleAssignmentsResultAsync(imageRepository, targetId, accessValidator, mapper), - "Organization" => GetRoleAssignmentsResultAsync(organizationRepository, targetId, accessValidator, mapper), - "Tournament" => GetRoleAssignmentsResultAsync(tournamentRepository, targetId, accessValidator, mapper), - "Venue" => GetRoleAssignmentsResultAsync(venueRepository, targetId, accessValidator, mapper), + "ApiKey" => GetRoleAssignmentsAsync(apiKeyRepository, targetId, accessValidator, mapper), + "Folder" => GetRoleAssignmentsAsync(folderRepository, targetId, accessValidator, mapper), + "Image" => GetRoleAssignmentsAsync(imageRepository, targetId, accessValidator, mapper), + "Organization" => GetRoleAssignmentsAsync(organizationRepository, targetId, accessValidator, mapper), + "Tournament" => GetRoleAssignmentsAsync(tournamentRepository, targetId, accessValidator, mapper), + "Venue" => GetRoleAssignmentsAsync(venueRepository, targetId, accessValidator, mapper), _ => null }; - return task is null + return task is null ? Results.BadRequest("Invalid scope identifier provided.") : await task.ConfigureAwait(false); } - private static async Task GetRoleAssignmentsResultAsync(IRepositoryWithPublicId repository, PublicId targetId, IAccessValidator accessValidator, IMapper mapper) + private static async Task GetRoleAssignmentsAsync(IRepositoryWithPublicId repository, PublicId targetId, IAccessValidator accessValidator, IMapper mapper) where T : Entity, IEntityWithRoleAssignments { var entity = await repository.GetByPublicIdAsync(targetId).ConfigureAwait(false); diff --git a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs index 0ef0217c..04b90a0d 100644 --- a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs +++ b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs @@ -30,6 +30,6 @@ public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out strin return true; } - [GeneratedRegex(@"^(?\w+)/(?[A-Za-z0-9_-]{11})$")] - private static partial Regex ScopeIdRegex(); + [GeneratedRegex(@"^(?ApiKey|Folder|Image|Organization|Tournament|Venue)/(?[A-Za-z0-9_-]{11})$")] + public static partial Regex ScopeIdRegex(); } diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index db540b9f..76e50c67 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -67,6 +67,11 @@ public RoleAssignment AddRoleAssignment(Role role, Principal principal, return roleAssignment; } + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } + public void AssignNewSecret(Func secretHashFunc, out string plainTextSecret) { plainTextSecret = GenerateSecret(); diff --git a/src/Turnierplan.Core/Folder/Folder.cs b/src/Turnierplan.Core/Folder/Folder.cs index 8cc9d827..3eecc3ad 100644 --- a/src/Turnierplan.Core/Folder/Folder.cs +++ b/src/Turnierplan.Core/Folder/Folder.cs @@ -48,4 +48,9 @@ public RoleAssignment AddRoleAssignment(Role role, Principal principal, return roleAssignment; } + + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } } diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index 9d48942d..2f88d043 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -73,6 +73,11 @@ public RoleAssignment AddRoleAssignment(Role role, Principal principal, s return roleAssignment; } + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } + private static void ValidateImageSize(ImageType type, ushort width, ushort height) { var constraints = ImageConstraints.GetImageConstraints(type); diff --git a/src/Turnierplan.Core/Organization/Organization.cs b/src/Turnierplan.Core/Organization/Organization.cs index 3bf20db6..377e745e 100644 --- a/src/Turnierplan.Core/Organization/Organization.cs +++ b/src/Turnierplan.Core/Organization/Organization.cs @@ -55,4 +55,9 @@ public RoleAssignment AddRoleAssignment(Role role, Principal princ return roleAssignment; } + + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } } diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs index 92e60677..50fe072e 100644 --- a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs +++ b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs @@ -8,4 +8,6 @@ public interface IEntityWithRoleAssignments : IEntityWithPublicId IReadOnlyList> RoleAssignments { get; } RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null); + + void RemoveRoleAssignment(RoleAssignment roleAssignment); } diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 62d54140..74ab2d14 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -155,6 +155,11 @@ public RoleAssignment AddRoleAssignment(Role role, Principal princip return roleAssignment; } + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } + public Team AddTeam(string name) { var team = new Team(GetNextId(), name); diff --git a/src/Turnierplan.Core/Venue/Venue.cs b/src/Turnierplan.Core/Venue/Venue.cs index e6a3eb85..0978ad7f 100644 --- a/src/Turnierplan.Core/Venue/Venue.cs +++ b/src/Turnierplan.Core/Venue/Venue.cs @@ -56,4 +56,9 @@ public RoleAssignment AddRoleAssignment(Role role, Principal principal, s return roleAssignment; } + + public void RemoveRoleAssignment(RoleAssignment roleAssignment) + { + _roleAssignments.Remove(roleAssignment); + } } From 7ee929a0f4657874f9c6ac4ae1ef571d19027480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:51:33 +0200 Subject: [PATCH 07/14] Fix path --- .../Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs index ad893579..6f7f16c6 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs @@ -16,7 +16,7 @@ internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase { protected override HttpMethod Method => HttpMethod.Delete; - protected override string Route => "/api/organizations/{scopeId}/{roleAssignmentId}"; + protected override string Route => "/api/role-assignments/{scopeId}/{roleAssignmentId}"; protected override Delegate Handler => Handle; From 255a32cfa9d510a6b828875c4ae883f6dc4432a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:52:31 +0200 Subject: [PATCH 08/14] scopeId => path parameter --- .../Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs index 778680a9..da41bcb4 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/GetRoleAssignmentsEndpoint.cs @@ -18,12 +18,12 @@ internal sealed class GetRoleAssignmentsEndpoint : EndpointBase HttpMethod.Get; - protected override string Route => "/api/role-assignments"; + protected override string Route => "/api/role-assignments/{scopeId}"; protected override Delegate Handler => Handle; private static async Task Handle( - [FromQuery] string scope, + [FromRoute] string scopeId, IApiKeyRepository apiKeyRepository, IFolderRepository folderRepository, IImageRepository imageRepository, @@ -33,7 +33,7 @@ private static async Task Handle( IAccessValidator accessValidator, IMapper mapper) { - if (!RbacScopeHelper.TryParseScopeId(scope, out var typeName, out var targetId)) + if (!RbacScopeHelper.TryParseScopeId(scopeId, out var typeName, out var targetId)) { return Results.BadRequest("Invalid scope identifier provided."); } From 29eb525185f645de4029c9c0e71e89c53218ae47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:54:07 +0200 Subject: [PATCH 09/14] Use : as scope id separatorr --- src/Turnierplan.App/Helpers/RbacScopeHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs index 04b90a0d..4805c003 100644 --- a/src/Turnierplan.App/Helpers/RbacScopeHelper.cs +++ b/src/Turnierplan.App/Helpers/RbacScopeHelper.cs @@ -10,7 +10,7 @@ internal static partial class RbacScopeHelper public static string GetScopeId(this T entity) where T : Entity, IEntityWithRoleAssignments { - return $"{typeof(T).Name}/{entity.PublicId.ToString()}"; + return $"{typeof(T).Name}:{entity.PublicId.ToString()}"; } public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out string? objectTypeName, out PublicId targetObjectId) @@ -30,6 +30,6 @@ public static bool TryParseScopeId(string scopeId, [NotNullWhen(true)] out strin return true; } - [GeneratedRegex(@"^(?ApiKey|Folder|Image|Organization|Tournament|Venue)/(?[A-Za-z0-9_-]{11})$")] + [GeneratedRegex(@"^(?ApiKey|Folder|Image|Organization|Tournament|Venue):(?[A-Za-z0-9_-]{11})$")] public static partial Regex ScopeIdRegex(); } From 92f28b2746f4d4890088eddbcd54072d7a0395de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 18:54:56 +0200 Subject: [PATCH 10/14] Description can be empty --- .../Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index f78f843c..a3d7f2a2 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -156,7 +156,6 @@ private Validator() .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserEmail)} must be specified."); RuleFor(x => x.Description) - .NotEmpty() .MaximumLength(ValidationConstants.RoleAssignment.MaxDescriptionLength); } } From 0efc4b8a71865c30f2cf4fb84c15f258e47c5d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 19:22:29 +0200 Subject: [PATCH 11/14] Fix create not working --- .../CreateRoleAssignmentEndpoint.cs | 15 ++++++----- .../DeleteRoleAssignmentEndpoint.cs | 2 +- .../IRoleAssignmentRepository.cs | 6 +++++ .../Extensions/ServiceCollectionExtensions.cs | 8 ++++++ .../RoleAssignmentRepositoryBase.cs | 26 +++++++++++++++++++ 5 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 src/Turnierplan.Core/RoleAssignment/IRoleAssignmentRepository.cs create mode 100644 src/Turnierplan.Dal/Repositories/RoleAssignmentRepositoryBase.cs diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index a3d7f2a2..87e2c892 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -38,6 +38,7 @@ private static async Task Handle( IUserRepository userRepository, IVenueRepository venueRepository, IAccessValidator accessValidator, + IServiceProvider serviceProvider, IMapper mapper, CancellationToken cancellationToken) { @@ -53,12 +54,12 @@ private static async Task Handle( var task = typeName switch { - "ApiKey" => CreateRoleAssignmentAsync(request, apiKeyRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), - "Folder" => CreateRoleAssignmentAsync(request, folderRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), - "Image" => CreateRoleAssignmentAsync(request, imageRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), - "Organization" => CreateRoleAssignmentAsync(request, organizationRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), - "Tournament" => CreateRoleAssignmentAsync(request, tournamentRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), - "Venue" => CreateRoleAssignmentAsync(request, venueRepository, targetId, accessValidator, apiKeyRepository, userRepository, mapper, cancellationToken), + "ApiKey" => CreateRoleAssignmentAsync(request, apiKeyRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), + "Folder" => CreateRoleAssignmentAsync(request, folderRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), + "Image" => CreateRoleAssignmentAsync(request, imageRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), + "Organization" => CreateRoleAssignmentAsync(request, organizationRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), + "Tournament" => CreateRoleAssignmentAsync(request, tournamentRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), + "Venue" => CreateRoleAssignmentAsync(request, venueRepository, targetId, accessValidator, apiKeyRepository, userRepository, serviceProvider.GetRequiredService>(), mapper, cancellationToken), _ => null }; @@ -74,6 +75,7 @@ private static async Task CreateRoleAssignmentAsync( IAccessValidator accessValidator, IApiKeyRepository apiKeyRepository, IUserRepository userRepository, + IRoleAssignmentRepository roleAssignmentRepository, IMapper mapper, CancellationToken cancellationToken) where T : Entity, IEntityWithRoleAssignments @@ -99,6 +101,7 @@ private static async Task CreateRoleAssignmentAsync( var roleAssignment = entity.AddRoleAssignment(request.Role, principal, request.Description); + await roleAssignmentRepository.CreateAsync(roleAssignment).ConfigureAwait(false); await repository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return Results.Ok(mapper.Map(roleAssignment)); diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs index 6f7f16c6..16d90e16 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs @@ -12,7 +12,7 @@ namespace Turnierplan.App.Endpoints.RoleAssignments; -internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase +internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase // TODO: Ensure the owner cant delete himself { protected override HttpMethod Method => HttpMethod.Delete; diff --git a/src/Turnierplan.Core/RoleAssignment/IRoleAssignmentRepository.cs b/src/Turnierplan.Core/RoleAssignment/IRoleAssignmentRepository.cs new file mode 100644 index 00000000..e2a745af --- /dev/null +++ b/src/Turnierplan.Core/RoleAssignment/IRoleAssignmentRepository.cs @@ -0,0 +1,6 @@ +using Turnierplan.Core.SeedWork; + +namespace Turnierplan.Core.RoleAssignment; + +public interface IRoleAssignmentRepository : IRepository, Guid> + where T : Entity, IEntityWithRoleAssignments; diff --git a/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs b/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs index c6225e73..4dad355c 100644 --- a/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs +++ b/src/Turnierplan.Dal/Extensions/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Turnierplan.Core.Folder; using Turnierplan.Core.Image; using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.Tournament; using Turnierplan.Core.User; using Turnierplan.Core.Venue; @@ -58,6 +59,13 @@ public static IServiceCollection AddTurnierplanDataAccessLayer(this IServiceColl services.AddScoped(); services.AddScoped(); + services.AddScoped, ApiKeyRoleAssignmentRepository>(); + services.AddScoped, FolderRoleAssignmentRepository>(); + services.AddScoped, ImageRoleAssignmentRepository>(); + services.AddScoped, OrganizationRoleAssignmentRepository>(); + services.AddScoped, TournamentRoleAssignmentRepository>(); + services.AddScoped, VenueRoleAssignmentRepository>(); + return services; } } diff --git a/src/Turnierplan.Dal/Repositories/RoleAssignmentRepositoryBase.cs b/src/Turnierplan.Dal/Repositories/RoleAssignmentRepositoryBase.cs new file mode 100644 index 00000000..6319ecc7 --- /dev/null +++ b/src/Turnierplan.Dal/Repositories/RoleAssignmentRepositoryBase.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.SeedWork; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.Venue; + +namespace Turnierplan.Dal.Repositories; + +internal abstract class RoleAssignmentRepositoryBase(TurnierplanContext context, DbSet> dbSet) : RepositoryBase, Guid>(context, dbSet), IRoleAssignmentRepository + where T : Entity, IEntityWithRoleAssignments; + +internal sealed class ApiKeyRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.ApiKeyRoleAssignments); + +internal sealed class FolderRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.FolderRoleAssignments); + +internal sealed class ImageRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.ImageRoleAssignments); + +internal sealed class OrganizationRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.OrganizationRoleAssignments); + +internal sealed class TournamentRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.TournamentRoleAssignments); + +internal sealed class VenueRoleAssignmentRepository(TurnierplanContext context) : RoleAssignmentRepositoryBase(context, context.VenueRoleAssignments); From f8e3d0e3da4ee20bba40e690cee4f33006753692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 19:25:51 +0200 Subject: [PATCH 12/14] Add create/delete restrictions --- .../CreateRoleAssignmentEndpoint.cs | 5 +++++ .../DeleteRoleAssignmentEndpoint.cs | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index 87e2c892..ddd14de3 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -99,6 +99,11 @@ private static async Task CreateRoleAssignmentAsync( return Results.BadRequest("Could not determine principal based on the provided information."); } + if (entity.RoleAssignments.Any(x => x.Role == request.Role && x.Principal.Equals(principal))) + { + return Results.Conflict("There already exists a role assignment for the specified principal/role combination."); + } + var roleAssignment = entity.AddRoleAssignment(request.Role, principal, request.Description); await roleAssignmentRepository.CreateAsync(roleAssignment).ConfigureAwait(false); diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs index 16d90e16..12326fac 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using SkiaSharp; using Turnierplan.App.Helpers; using Turnierplan.App.Security; using Turnierplan.Core.ApiKey; @@ -6,13 +7,14 @@ using Turnierplan.Core.Image; using Turnierplan.Core.Organization; using Turnierplan.Core.PublicId; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; using Turnierplan.Core.Tournament; using Turnierplan.Core.Venue; namespace Turnierplan.App.Endpoints.RoleAssignments; -internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase // TODO: Ensure the owner cant delete himself +internal sealed class DeleteRoleAssignmentEndpoint : EndpointBase { protected override HttpMethod Method => HttpMethod.Delete; @@ -87,6 +89,16 @@ private static async Task DeleteRoleAssignmentAsync( entity.RemoveRoleAssignment(roleAssignment); + if (entity is Organization organization) + { + // An organization must always have at least one owner + + if (!organization.RoleAssignments.Any(x => x.Role is Role.Owner)) + { + return Results.BadRequest("When deleting role assignments from an Organization, at least one owner must always remain."); + } + } + await repository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return Results.NoContent(); From ab1d923a656eef19e858208656f179c13d0f7858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 19:33:22 +0200 Subject: [PATCH 13/14] Fix code smell --- .../RoleAssignments/DeleteRoleAssignmentEndpoint.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs index 12326fac..f255d6e3 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/DeleteRoleAssignmentEndpoint.cs @@ -89,14 +89,11 @@ private static async Task DeleteRoleAssignmentAsync( entity.RemoveRoleAssignment(roleAssignment); - if (entity is Organization organization) + if (entity is Organization organization && !organization.RoleAssignments.Any(x => x.Role is Role.Owner)) { // An organization must always have at least one owner - if (!organization.RoleAssignments.Any(x => x.Role is Role.Owner)) - { - return Results.BadRequest("When deleting role assignments from an Organization, at least one owner must always remain."); - } + return Results.BadRequest("When deleting role assignments from an Organization, at least one owner must always remain."); } await repository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); From 3c277e92bfbcc2e31c4f3d2e3512b97f2cef7bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 19:49:00 +0200 Subject: [PATCH 14/14] Fix migration --- src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs index d6800797..99d20e83 100644 --- a/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs +++ b/src/Turnierplan.Dal/Migrations/20250615154535_Add_RBAC.cs @@ -226,8 +226,8 @@ protected override void Up(MigrationBuilder migrationBuilder) // 1000 is the numerical value for the "Owner" role migrationBuilder.Sql(""" -INSERT INTO turnierplan."IAM_Organization" ("OrganizationId", "CreatedAt", "Role", "Principal", "Description") -SELECT "Organizations"."Id", NOW(), 1000, ('User:' || "Organizations"."OwnerId"), '' +INSERT INTO turnierplan."IAM_Organization" ("Id", "OrganizationId", "CreatedAt", "Role", "Principal", "Description") +SELECT gen_random_uuid(), "Organizations"."Id", NOW(), 1000, ('User:' || "Organizations"."OwnerId"), '' FROM turnierplan."Organizations"; """);