From 20c324b2020fb50e9e620d6fc3bc28e0ad993fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 12:41:00 +0200 Subject: [PATCH 01/13] Integrate role assignments into domain model --- src/Turnierplan.Core/ApiKey/ApiKey.cs | 16 ++++++-- .../Extensions/PrincipalExtensions.cs | 16 ++++++++ src/Turnierplan.Core/Folder/Folder.cs | 16 ++++++-- src/Turnierplan.Core/Image/Image.cs | 17 +++++++-- .../Organization/Organization.cs | 20 +++++++--- .../RoleAssignment/Principal.cs | 18 +++++++++ .../RoleAssignment/PrincipalKind.cs | 7 ++++ src/Turnierplan.Core/RoleAssignment/Role.cs | 21 ++++++++++ .../RoleAssignment/RoleAssignment.cs | 38 +++++++++++++++++++ .../SeedWork/IEntityWithOwner.cs | 6 --- .../SeedWork/IEntityWithRoleAssignments.cs | 9 +++++ src/Turnierplan.Core/Tournament/Group.cs | 2 +- src/Turnierplan.Core/Tournament/Tournament.cs | 22 ++++++++--- src/Turnierplan.Core/User/Role.cs | 2 +- src/Turnierplan.Core/User/User.cs | 4 +- src/Turnierplan.Core/Venue/Venue.cs | 16 ++++++-- .../Converters/RoleAssignmentConverter.cs | 34 +++++++++++++++++ .../ApiKeyEntityTypeConfiguration.cs | 7 ++++ .../FolderEntityTypeConfiguration.cs | 7 ++++ .../ImageEntityTypeConfiguration.cs | 8 ++++ .../OrganizationEntityTypeConfiguration.cs | 11 ++++-- .../RoleAssignmentEntityTypeConfiguration.cs | 35 +++++++++++++++++ .../TournamentEntityTypeConfiguration.cs | 10 +++-- .../VenueEntityTypeConfiguration.cs | 7 ++++ src/Turnierplan.Dal/TurnierplanContext.cs | 21 ++++++++++ src/Turnierplan.Dal/ValidationConstants.cs | 6 ++- 26 files changed, 334 insertions(+), 42 deletions(-) create mode 100644 src/Turnierplan.Core/Extensions/PrincipalExtensions.cs create mode 100644 src/Turnierplan.Core/RoleAssignment/Principal.cs create mode 100644 src/Turnierplan.Core/RoleAssignment/PrincipalKind.cs create mode 100644 src/Turnierplan.Core/RoleAssignment/Role.cs create mode 100644 src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs delete mode 100644 src/Turnierplan.Core/SeedWork/IEntityWithOwner.cs create mode 100644 src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs create mode 100644 src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs create mode 100644 src/Turnierplan.Dal/EntityConfigurations/RoleAssignmentEntityTypeConfiguration.cs diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index 8c85f73f..29b7a21c 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -1,10 +1,12 @@ +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.ApiKey; -public sealed class ApiKey : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class ApiKey : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { - internal List _requests = new(); + internal readonly List> _roleAssignments = new(); + internal readonly List _requests = new(); public ApiKey(Organization.Organization organization, string name, string? description, DateTime expiryDate) { @@ -39,7 +41,7 @@ internal ApiKey(long id, PublicId.PublicId publicId, string name, string descrip public Organization.Organization Organization { get; internal set; } = null!; - Guid IEntityWithOwner.OwnerId => Organization.OwnerId; + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); public string Name { get; } @@ -57,6 +59,14 @@ internal ApiKey(long id, PublicId.PublicId publicId, string name, string descrip public IReadOnlyList Requests => _requests.AsReadOnly(); + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return roleAssignment; + } + public void AssignNewSecret(Func secretHashFunc, out string plainTextSecret) { plainTextSecret = GenerateSecret(); diff --git a/src/Turnierplan.Core/Extensions/PrincipalExtensions.cs b/src/Turnierplan.Core/Extensions/PrincipalExtensions.cs new file mode 100644 index 00000000..9e74bf4c --- /dev/null +++ b/src/Turnierplan.Core/Extensions/PrincipalExtensions.cs @@ -0,0 +1,16 @@ +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.Core.Extensions; + +public static class PrincipalExtensions +{ + public static Principal AsPrincipal(this ApiKey.ApiKey apiKey) + { + return new Principal(PrincipalKind.ApiKey, apiKey.Id.ToString()); + } + + public static Principal AsPrincipal(this User.User user) + { + return new Principal(PrincipalKind.User, user.Id.ToString()); + } +} diff --git a/src/Turnierplan.Core/Folder/Folder.cs b/src/Turnierplan.Core/Folder/Folder.cs index c4e1c217..04f68a63 100644 --- a/src/Turnierplan.Core/Folder/Folder.cs +++ b/src/Turnierplan.Core/Folder/Folder.cs @@ -1,10 +1,12 @@ +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.Folder; -public sealed class Folder : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class Folder : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { - internal List _tournaments = new(); + internal readonly List> _roleAssignments = new(); + internal readonly List _tournaments = new(); public Folder(Organization.Organization organization, string name) { @@ -31,11 +33,19 @@ internal Folder(long id, PublicId.PublicId publicId, DateTime createdAt, string public Organization.Organization Organization { get; internal set; } = null!; - Guid IEntityWithOwner.OwnerId => Organization.OwnerId; + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); public DateTime CreatedAt { get; } public string Name { get; set; } public IReadOnlyList Tournaments => _tournaments.AsReadOnly(); + + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return roleAssignment; + } } diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index 4d7ef4ab..55a6f9e0 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -1,10 +1,13 @@ using Turnierplan.Core.Exceptions; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.Image; -public sealed class Image : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class Image : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { + internal readonly List> _roleAssignments = new(); + public Image(Organization.Organization organization, string name, ImageType type, string fileType, long fileSize, ushort width, ushort height) { ValidateImageSize(type, width, height); @@ -39,14 +42,14 @@ internal Image(long id, Guid resourceIdentifier, PublicId.PublicId publicId, Dat public override long Id { get; protected set; } + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); + public Guid ResourceIdentifier { get; } public PublicId.PublicId PublicId { get; } public Organization.Organization Organization { get; internal set; } = null!; - Guid IEntityWithOwner.OwnerId => Organization.OwnerId; - public DateTime CreatedAt { get; } public string Name { get; set; } @@ -61,6 +64,14 @@ internal Image(long id, Guid resourceIdentifier, PublicId.PublicId publicId, Dat public ushort Height { get; } + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return 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 a0a46ddb..c96c8d89 100644 --- a/src/Turnierplan.Core/Organization/Organization.cs +++ b/src/Turnierplan.Core/Organization/Organization.cs @@ -1,9 +1,11 @@ +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.Organization; -public sealed class Organization : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class Organization : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { + internal readonly List> _roleAssignments = new(); internal readonly List _apiKeys = new(); internal readonly List _folders = new(); internal readonly List _images = new(); @@ -18,28 +20,26 @@ public Organization(User.User owner, string name) PublicId = new PublicId.PublicId(); CreatedAt = DateTime.UtcNow; Name = name; - OwnerId = owner.Id; } - internal Organization(long id, PublicId.PublicId publicId, DateTime createdAt, string name, Guid ownerId) + internal Organization(long id, PublicId.PublicId publicId, DateTime createdAt, string name) { Id = id; PublicId = publicId; CreatedAt = createdAt; Name = name; - OwnerId = ownerId; } public override long Id { get; protected set; } public PublicId.PublicId PublicId { get; } + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); + public DateTime CreatedAt { get; } public string Name { get; set; } - public Guid OwnerId { get; } - public IReadOnlyList ApiKeys => _apiKeys.AsReadOnly(); public IReadOnlyList Folders => _folders.AsReadOnly(); @@ -49,4 +49,12 @@ internal Organization(long id, PublicId.PublicId publicId, DateTime createdAt, s public IReadOnlyList Tournaments => _tournaments.AsReadOnly(); public IReadOnlyList Venues => _venues.AsReadOnly(); + + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return roleAssignment; + } } diff --git a/src/Turnierplan.Core/RoleAssignment/Principal.cs b/src/Turnierplan.Core/RoleAssignment/Principal.cs new file mode 100644 index 00000000..536bd2e4 --- /dev/null +++ b/src/Turnierplan.Core/RoleAssignment/Principal.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Turnierplan.Core.RoleAssignment; + +public sealed record Principal +{ + public Principal(PrincipalKind kind, string objectId) + { + Kind = kind; + ObjectId = objectId; + } + + [JsonPropertyName("k")] + public PrincipalKind Kind { get; } + + [JsonPropertyName("oid")] + public string ObjectId { get; } +} diff --git a/src/Turnierplan.Core/RoleAssignment/PrincipalKind.cs b/src/Turnierplan.Core/RoleAssignment/PrincipalKind.cs new file mode 100644 index 00000000..e9ff7af7 --- /dev/null +++ b/src/Turnierplan.Core/RoleAssignment/PrincipalKind.cs @@ -0,0 +1,7 @@ +namespace Turnierplan.Core.RoleAssignment; + +public enum PrincipalKind +{ + ApiKey, + User +} diff --git a/src/Turnierplan.Core/RoleAssignment/Role.cs b/src/Turnierplan.Core/RoleAssignment/Role.cs new file mode 100644 index 00000000..769e348b --- /dev/null +++ b/src/Turnierplan.Core/RoleAssignment/Role.cs @@ -0,0 +1,21 @@ +namespace Turnierplan.Core.RoleAssignment; + +public enum Role +{ + /// + /// This role grants all permissions on the target scope including the rights + /// to delete the entity and modify role assignments. + /// + Owner, + + /// + /// This role grants all permissions on the target scope excluding the rights + /// to delete the entity and modify role assignments. + /// + Contributor, + + /// + /// This role grants the permission to view the target entity but not to make modifications. + /// + Reader +} diff --git a/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs new file mode 100644 index 00000000..75cdd1cf --- /dev/null +++ b/src/Turnierplan.Core/RoleAssignment/RoleAssignment.cs @@ -0,0 +1,38 @@ +using Turnierplan.Core.SeedWork; + +namespace Turnierplan.Core.RoleAssignment; + +public sealed class RoleAssignment : Entity + where T : Entity, IEntityWithRoleAssignments +{ + internal RoleAssignment(T scope, Role role, Principal principal, string? description = null) + { + Id = 0; + Scope = scope; + CreatedAt = DateTime.UtcNow; + Role = role; + Principal = principal; + Description = description ?? string.Empty; + } + + internal RoleAssignment(long id, DateTime createdAt, Role role, Principal principal, string description) + { + Id = id; + CreatedAt = createdAt; + Role = role; + Principal = principal; + Description = description; + } + + public override long Id { get; protected set; } + + public T Scope { get; internal set; } = null!; + + public DateTime CreatedAt { get; } + + public Role Role { get; } + + public Principal Principal { get; } + + public string Description { get; set; } +} diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithOwner.cs b/src/Turnierplan.Core/SeedWork/IEntityWithOwner.cs deleted file mode 100644 index 65c105ed..00000000 --- a/src/Turnierplan.Core/SeedWork/IEntityWithOwner.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Turnierplan.Core.SeedWork; - -public interface IEntityWithOwner -{ - public Guid OwnerId { get; } -} diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs new file mode 100644 index 00000000..1578dfd2 --- /dev/null +++ b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs @@ -0,0 +1,9 @@ +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.Core.SeedWork; + +public interface IEntityWithRoleAssignments + where T : Entity, IEntityWithRoleAssignments +{ + RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null); +} diff --git a/src/Turnierplan.Core/Tournament/Group.cs b/src/Turnierplan.Core/Tournament/Group.cs index 8215c986..09a6b814 100644 --- a/src/Turnierplan.Core/Tournament/Group.cs +++ b/src/Turnierplan.Core/Tournament/Group.cs @@ -4,7 +4,7 @@ namespace Turnierplan.Core.Tournament; public sealed class Group : Entity { - internal List _participants = []; + internal readonly List _participants = []; internal Group(int id, char alphabeticalId, string? displayName = null) { diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 7b662ee4..135033a5 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -1,6 +1,7 @@ using Turnierplan.Core.Exceptions; using Turnierplan.Core.Extensions; using Turnierplan.Core.Image; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; using Turnierplan.Core.Tournament.Comparers; using Turnierplan.Core.Tournament.Definitions; @@ -8,15 +9,16 @@ namespace Turnierplan.Core.Tournament; -public sealed class Tournament : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class Tournament : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { internal readonly GroupParticipantComparer _groupParticipantComparer; internal int? _nextEntityId; - internal List _teams = new(); - internal List _groups = new(); - internal List _matches = new(); - internal List _documents = new(); + internal readonly List> _roleAssignments = new(); + internal readonly List _teams = new(); + internal readonly List _groups = new(); + internal readonly List _matches = new(); + internal readonly List _documents = new(); public Tournament(Organization.Organization organization, string name, Visibility visibility) { @@ -57,7 +59,7 @@ internal Tournament(long id, PublicId.PublicId publicId, bool isMigrated, DateTi public Organization.Organization Organization { get; internal set; } = null!; - Guid IEntityWithOwner.OwnerId => Organization.OwnerId; + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); public bool IsMigrated { get; } @@ -145,6 +147,14 @@ public DateTime? EndTimestamp } } + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return roleAssignment; + } + public Team AddTeam(string name) { var team = new Team(GetNextId(), name); diff --git a/src/Turnierplan.Core/User/Role.cs b/src/Turnierplan.Core/User/Role.cs index 4573d477..984715e1 100644 --- a/src/Turnierplan.Core/User/Role.cs +++ b/src/Turnierplan.Core/User/Role.cs @@ -2,7 +2,7 @@ namespace Turnierplan.Core.User; -public sealed class Role : Entity +public sealed class Role : Entity // TODO: Delete { internal Role(Guid id, string name) { diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index c2ec85da..3e93580d 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -5,8 +5,8 @@ namespace Turnierplan.Core.User; public sealed class User : Entity { - internal List _organizations = new(); - internal List _roles = new(); + internal readonly List _organizations = new(); + internal readonly List _roles = new(); public User(string name, string email) { diff --git a/src/Turnierplan.Core/Venue/Venue.cs b/src/Turnierplan.Core/Venue/Venue.cs index f71e87c6..1de0f9ba 100644 --- a/src/Turnierplan.Core/Venue/Venue.cs +++ b/src/Turnierplan.Core/Venue/Venue.cs @@ -1,10 +1,12 @@ +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.Venue; -public sealed class Venue : Entity, IEntityWithPublicId, IEntityWithOwner +public sealed class Venue : Entity, IEntityWithPublicId, IEntityWithRoleAssignments { - internal List _tournaments = new(); + internal readonly List> _roleAssignments = new(); + internal readonly List _tournaments = new(); public Venue(Organization.Organization organization, string name, string description) { @@ -33,7 +35,7 @@ internal Venue(long id, PublicId.PublicId publicId, DateTime createdAt, string n public Organization.Organization Organization { get; internal set; } = null!; - Guid IEntityWithOwner.OwnerId => Organization.OwnerId; + public IReadOnlyList> RoleAssignments => _roleAssignments.AsReadOnly(); public DateTime CreatedAt { get; } @@ -46,4 +48,12 @@ internal Venue(long id, PublicId.PublicId publicId, DateTime createdAt, string n public List ExternalLinks { get; set; } = new(); public IReadOnlyList Tournaments => _tournaments.AsReadOnly(); + + public RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null) + { + var roleAssignment = new RoleAssignment(this, role, principal, description); + _roleAssignments.Add(roleAssignment); + + return roleAssignment; + } } diff --git a/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs b/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs new file mode 100644 index 00000000..b2cee0af --- /dev/null +++ b/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs @@ -0,0 +1,34 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Turnierplan.Core.Exceptions; +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.Dal.Converters; + +internal sealed partial class PrincipalConverter : ValueConverter +{ + public PrincipalConverter() + : base(principal => FormatPrincipal(principal), str => ParsePrincipal(str)) + { + } + + private static string FormatPrincipal(Principal principal) + { + return $"{principal.Kind}:{principal.ObjectId}"; + } + + private static Principal ParsePrincipal(string input) + { + var match = PrincipalRegex().Match(input); + + if (!match.Success || !Enum.TryParse(match.Groups["Kind"].Value, out var kind)) + { + throw new TurnierplanException("Invalid principal string."); + } + + return new Principal(kind, match.Groups["ObjectId"].Value); + } + + [GeneratedRegex("^(?:(?ApiKey):(?\\d+))|(?:(?User):(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))$")] + private static partial Regex PrincipalRegex(); +} diff --git a/src/Turnierplan.Dal/EntityConfigurations/ApiKeyEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/ApiKeyEntityTypeConfiguration.cs index ff0e9446..6720878e 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/ApiKeyEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/ApiKeyEntityTypeConfiguration.cs @@ -22,6 +22,12 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.PublicId) .IsUnique(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.Name) .IsRequired() .HasMaxLength(ValidationConstants.ApiKey.MaxNameLength); @@ -50,6 +56,7 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + builder.Metadata.FindNavigation(nameof(ApiKey.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); builder.Metadata.FindNavigation(nameof(ApiKey.Requests))!.SetPropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Turnierplan.Dal/EntityConfigurations/FolderEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/FolderEntityTypeConfiguration.cs index 82e347bc..d10ec9e4 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/FolderEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/FolderEntityTypeConfiguration.cs @@ -22,6 +22,12 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.PublicId) .IsUnique(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.CreatedAt) .IsRequired(); @@ -34,6 +40,7 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey("FolderId") .OnDelete(DeleteBehavior.SetNull); + builder.Metadata.FindNavigation(nameof(Folder.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); builder.Metadata.FindNavigation(nameof(Folder.Tournaments))!.SetPropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs index e37ba122..233e81d3 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs @@ -16,6 +16,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Id) .IsRequired(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.ResourceIdentifier) .IsRequired(); @@ -50,5 +56,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Height) .IsRequired(); + + builder.Metadata.FindNavigation(nameof(Image.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Turnierplan.Dal/EntityConfigurations/OrganizationEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/OrganizationEntityTypeConfiguration.cs index 51e81218..c95e65cf 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/OrganizationEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/OrganizationEntityTypeConfiguration.cs @@ -22,6 +22,12 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.PublicId) .IsUnique(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.CreatedAt) .IsRequired(); @@ -59,14 +65,11 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + builder.Metadata.FindNavigation(nameof(Organization.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); builder.Metadata.FindNavigation(nameof(Organization.ApiKeys))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Organization.Folders))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Organization.Images))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Organization.Tournaments))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Organization.Venues))!.SetPropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Turnierplan.Dal/EntityConfigurations/RoleAssignmentEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/RoleAssignmentEntityTypeConfiguration.cs new file mode 100644 index 00000000..c81deafa --- /dev/null +++ b/src/Turnierplan.Dal/EntityConfigurations/RoleAssignmentEntityTypeConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.SeedWork; +using Turnierplan.Dal.Converters; + +namespace Turnierplan.Dal.EntityConfigurations; + +public sealed class RoleAssignmentEntityTypeConfiguration : IEntityTypeConfiguration> + where T : Entity, IEntityWithRoleAssignments +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable($"IAM_{typeof(T).Name}", TurnierplanContext.Schema); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .IsRequired(); + + builder.Property(x => x.CreatedAt) + .IsRequired(); + + builder.Property(x => x.Role) + .IsRequired(); + + builder.Property(x => x.Principal) + .IsRequired() + .HasConversion(); + + builder.Property(x => x.Description) + .IsRequired() + .HasMaxLength(ValidationConstants.RoleAssignment.MaxDescriptionLength); + } +} diff --git a/src/Turnierplan.Dal/EntityConfigurations/TournamentEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/TournamentEntityTypeConfiguration.cs index e0bca0d2..346503cb 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/TournamentEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/TournamentEntityTypeConfiguration.cs @@ -23,6 +23,12 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.PublicId) .IsUnique(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.IsMigrated) .IsRequired(); @@ -109,12 +115,10 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + builder.Metadata.FindNavigation(nameof(Tournament.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); builder.Metadata.FindNavigation(nameof(Tournament.Teams))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Tournament.Groups))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Tournament.Matches))!.SetPropertyAccessMode(PropertyAccessMode.Field); - builder.Metadata.FindNavigation(nameof(Tournament.Documents))!.SetPropertyAccessMode(PropertyAccessMode.Field); } diff --git a/src/Turnierplan.Dal/EntityConfigurations/VenueEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/VenueEntityTypeConfiguration.cs index a6f03433..312bf0b9 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/VenueEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/VenueEntityTypeConfiguration.cs @@ -22,6 +22,12 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.PublicId) .IsUnique(); + builder.HasMany(x => x.RoleAssignments) + .WithOne(x => x.Scope) + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + builder.Property(x => x.CreatedAt) .IsRequired(); @@ -42,6 +48,7 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey("VenueId") .OnDelete(DeleteBehavior.SetNull); + builder.Metadata.FindNavigation(nameof(Venue.RoleAssignments))!.SetPropertyAccessMode(PropertyAccessMode.Field); builder.Metadata.FindNavigation(nameof(Venue.Tournaments))!.SetPropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/src/Turnierplan.Dal/TurnierplanContext.cs b/src/Turnierplan.Dal/TurnierplanContext.cs index 1cedca29..99cdc7b8 100644 --- a/src/Turnierplan.Dal/TurnierplanContext.cs +++ b/src/Turnierplan.Dal/TurnierplanContext.cs @@ -6,11 +6,13 @@ 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.User; using Turnierplan.Core.Venue; using Turnierplan.Dal.EntityConfigurations; +using Role = Turnierplan.Core.User.Role; namespace Turnierplan.Dal; @@ -30,32 +32,44 @@ public TurnierplanContext(DbContextOptions options, ILogger< public DbSet ApiKeys { get; set; } = null!; + public DbSet> ApiKeyRoleAssignments { get; set; } = null!; + public DbSet ApiKeyRequests { get; set; } = null!; public DbSet Documents { get; set; } = null!; public DbSet Folders { get; set; } = null!; + public DbSet> FolderRoleAssignments { get; set; } = null!; + public DbSet Groups { get; set; } = null!; public DbSet GroupParticipants { get; set; } = null!; public DbSet Images { get; set; } = null!; + public DbSet> ImageRoleAssignments { get; set; } = null!; + public DbSet Matches { get; set; } = null!; public DbSet Organizations { get; set; } = null!; + public DbSet> OrganizationRoleAssignments { get; set; } = null!; + public DbSet Roles { get; set; } = null!; public DbSet Teams { get; set; } = null!; public DbSet Tournaments { get; set; } = null!; + public DbSet> TournamentRoleAssignments { get; set; } = null!; + public DbSet Users { get; set; } = null!; public DbSet Venues { get; set; } = null!; + public DbSet> VenueRoleAssignments { get; set; } = null!; + public async Task BeginTransactionAsync() { if (_activeTransaction is not null) @@ -135,5 +149,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new TournamentEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new UserEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new VenueEntityTypeConfiguration()); + + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RoleAssignmentEntityTypeConfiguration()); } } diff --git a/src/Turnierplan.Dal/ValidationConstants.cs b/src/Turnierplan.Dal/ValidationConstants.cs index 34abeee5..e7570da2 100644 --- a/src/Turnierplan.Dal/ValidationConstants.cs +++ b/src/Turnierplan.Dal/ValidationConstants.cs @@ -6,7 +6,6 @@ public static class ApiKey { public const int MaxNameLength = 25; public const int MaxDescriptionLength = 250; - public const int MaxSecretLength = 30; } public static class ApiKeyRequest @@ -52,6 +51,11 @@ public static class Role public const int MaxNameLength = 16; } + public static class RoleAssignment + { + public const int MaxDescriptionLength = 250; + } + public static class Team { public const int MaxNameLength = 60; From ef3bde940d4de684b2d886866e1fc85845daffc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 13:41:35 +0200 Subject: [PATCH 02/13] Remove "roles" --- .../core/services/authentication.service.ts | 32 +++++-------------- src/Turnierplan.App/Client/src/app/i18n/de.ts | 3 +- ...ctive.ts => is-administrator.directive.ts} | 27 ++++++---------- ...{has-role.guard.ts => is-administrator.ts} | 6 ++-- .../Client/src/app/portal/helpers/role-ids.ts | 3 -- .../administration-page.component.html | 10 ++---- .../administration-page.component.ts | 10 ------ .../landing-page/landing-page.component.html | 6 ++-- .../landing-page/landing-page.component.ts | 3 -- .../src/app/portal/portal.component.html | 2 +- .../Client/src/app/portal/portal.component.ts | 4 --- .../Client/src/app/portal/portal.module.ts | 11 +++---- src/Turnierplan.App/Endpoints/EndpointBase.cs | 11 +++---- .../Identity/IdentityEndpointBase.cs | 7 +++- .../Endpoints/Users/CreateUserEndpoint.cs | 2 +- .../Endpoints/Users/DeleteUserEndpoint.cs | 2 +- .../Endpoints/Users/GetUsersEndpoint.cs | 2 +- .../Extensions/WebApplicationExtensions.cs | 10 +++--- .../Mapping/Rules/UserMappingRule.cs | 6 +--- src/Turnierplan.App/Models/UserDto.cs | 2 +- .../Security/AccessValidator.cs | 8 +++-- src/Turnierplan.App/Security/ClaimTypes.cs | 1 + src/Turnierplan.Core/User/Role.cs | 16 ---------- src/Turnierplan.Core/User/User.cs | 25 +++------------ src/Turnierplan.Core/User/UserRoles.cs | 6 ---- .../RoleEntityTypeConfiguration.cs | 27 ---------------- .../UserEntityTypeConfiguration.cs | 6 ---- .../Repositories/OrganizationRepository.cs | 5 ++- .../Repositories/UserRepository.cs | 4 --- src/Turnierplan.Dal/TurnierplanContext.cs | 4 --- 30 files changed, 71 insertions(+), 190 deletions(-) rename src/Turnierplan.App/Client/src/app/portal/directives/has-role/{has-role.directive.ts => is-administrator.directive.ts} (50%) rename src/Turnierplan.App/Client/src/app/portal/guards/{has-role.guard.ts => is-administrator.ts} (59%) delete mode 100644 src/Turnierplan.App/Client/src/app/portal/helpers/role-ids.ts delete mode 100644 src/Turnierplan.Core/User/Role.cs delete mode 100644 src/Turnierplan.Core/User/UserRoles.cs delete mode 100644 src/Turnierplan.Dal/EntityConfigurations/RoleEntityTypeConfiguration.cs diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index df487de5..7eab7c66 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -10,7 +10,7 @@ interface TurnierplanAccessToken { exp: number; mail: string; name: string; - rol: string | string[]; + adm?: string; uid: string; } @@ -24,7 +24,7 @@ export class AuthenticationService implements OnDestroy { private static readonly localStorageUserIdKey = 'tp_id_userId'; private static readonly localStorageUserNameKey = 'tp_id_userName'; private static readonly localStorageUserEMailKey = 'tp_id_userEMail'; - private static readonly localStorageUserRolesKey = 'tp_id_userRoles'; + private static readonly localStorageUserAdministratorKey = 'tp_id_userAdmin'; private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp'; private static readonly localStorageRefreshTokenExpiryKey = 'tp_id_rfsTokenExp'; private static readonly refreshAccessTokenIfExpiresInLessThanSeconds = 300; @@ -66,7 +66,7 @@ export class AuthenticationService implements OnDestroy { decodedAccessToken.uid, decodedAccessToken.name, decodedAccessToken.mail, - decodedAccessToken.rol, + decodedAccessToken.adm === 'true', decodedAccessToken.exp, decodedRefreshToken.exp ); @@ -119,17 +119,8 @@ export class AuthenticationService implements OnDestroy { return expiry !== undefined && expiry * 1000 > new Date().getTime(); } - public checkIfUserHasRole(roleId: string): Observable { - return this.authentication$.pipe( - map(() => { - const storedValue = localStorage.getItem(AuthenticationService.localStorageUserRolesKey); - if (storedValue === null || storedValue === '') { - return false; - } - - return storedValue.split(';').some((x) => x === roleId); - }) - ); + public checkIfUserIsAdministrator(): Observable { + return this.authentication$.pipe(map(() => localStorage.getItem(AuthenticationService.localStorageUserAdministratorKey) === 'true')); } public changePassword( @@ -214,7 +205,7 @@ export class AuthenticationService implements OnDestroy { decodedAccessToken.uid, decodedAccessToken.name, decodedAccessToken.mail, - decodedAccessToken.rol, + decodedAccessToken.adm === 'true', decodedAccessToken.exp, decodedRefreshToken.exp ); @@ -295,21 +286,14 @@ export class AuthenticationService implements OnDestroy { userId: string, userName: string, userEMail: string, - userRoles: string | string[], + userIsAdmin: boolean, accessTokenExpiry: number, refreshTokenExpiry: number ): void { localStorage.setItem(AuthenticationService.localStorageUserIdKey, userId); localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail); - - if (userRoles === undefined || userRoles === '' || (userRoles as string[])?.length === 0) { - localStorage.removeItem(AuthenticationService.localStorageUserRolesKey); - } else if (typeof userRoles === 'string') { - localStorage.setItem(AuthenticationService.localStorageUserRolesKey, userRoles); - } else { - localStorage.setItem(AuthenticationService.localStorageUserRolesKey, userRoles.join(';')); - } + localStorage.setItem(AuthenticationService.localStorageUserAdministratorKey, `${userIsAdmin}`); localStorage.setItem(AuthenticationService.localStorageAccessTokenExpiryKey, `${accessTokenExpiry}`); localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 7c8fc090..e9f5f760 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -118,8 +118,7 @@ export const de = { EMail: 'E-Mail', CreatedAt: 'Erstellt am', LastPasswordChange: 'Letzte Passwortänderung', - Roles: 'Rollen', - NoRoles: 'keine' + Administrator: 'Admin' }, DeleteUser: { Title: 'Benutzer löschen', diff --git a/src/Turnierplan.App/Client/src/app/portal/directives/has-role/has-role.directive.ts b/src/Turnierplan.App/Client/src/app/portal/directives/has-role/is-administrator.directive.ts similarity index 50% rename from src/Turnierplan.App/Client/src/app/portal/directives/has-role/has-role.directive.ts rename to src/Turnierplan.App/Client/src/app/portal/directives/has-role/is-administrator.directive.ts index 595ba989..ab1c65fc 100644 --- a/src/Turnierplan.App/Client/src/app/portal/directives/has-role/has-role.directive.ts +++ b/src/Turnierplan.App/Client/src/app/portal/directives/has-role/is-administrator.directive.ts @@ -1,14 +1,13 @@ -import { Directive, ElementRef, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; -import { ReplaySubject, Subject, switchMap, takeUntil } from 'rxjs'; +import { Directive, ElementRef, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; +import { Subject, takeUntil } from 'rxjs'; import { AuthenticationService } from '../../../core/services/authentication.service'; @Directive({ standalone: false, - selector: '[tpHasRole]' + selector: '[tpIsAdministrator]' }) -export class HasRoleDirective implements OnInit, OnDestroy { - private readonly roleId$ = new ReplaySubject(1); +export class IsAdministratorDirective implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); constructor( @@ -17,21 +16,14 @@ export class HasRoleDirective implements OnInit, OnDestroy { private readonly authenticationService: AuthenticationService ) {} - @Input() - public set tpHasRole(roleId: string) { - this.roleId$.next(roleId); - } - public ngOnInit(): void { - this.roleId$ - .pipe( - switchMap((roleId) => this.authenticationService.checkIfUserHasRole(roleId)), - takeUntil(this.destroyed$) - ) + this.authenticationService + .checkIfUserIsAdministrator() + .pipe(takeUntil(this.destroyed$)) .subscribe({ - next: (hasRole) => { + next: (isAdministrator) => { this.viewContainer.clear(); - if (hasRole) { + if (isAdministrator) { this.viewContainer.createEmbeddedView(this.templateRef); } } @@ -39,7 +31,6 @@ export class HasRoleDirective implements OnInit, OnDestroy { } public ngOnDestroy(): void { - this.roleId$.complete(); this.destroyed$.next(); this.destroyed$.complete(); } diff --git a/src/Turnierplan.App/Client/src/app/portal/guards/has-role.guard.ts b/src/Turnierplan.App/Client/src/app/portal/guards/is-administrator.ts similarity index 59% rename from src/Turnierplan.App/Client/src/app/portal/guards/has-role.guard.ts rename to src/Turnierplan.App/Client/src/app/portal/guards/is-administrator.ts index f80d1eeb..899f1c01 100644 --- a/src/Turnierplan.App/Client/src/app/portal/guards/has-role.guard.ts +++ b/src/Turnierplan.App/Client/src/app/portal/guards/is-administrator.ts @@ -4,11 +4,13 @@ import { map } from 'rxjs'; import { AuthenticationService } from '../../core/services/authentication.service'; -export const hasRoleGuard = (roleId: string): CanActivateFn => { +export const isAdministratorGuard = (): CanActivateFn => { return () => { const router = inject(Router); const authenticationService = inject(AuthenticationService); - return authenticationService.checkIfUserHasRole(roleId).pipe(map((hasRole) => (hasRole ? true : router.createUrlTree(['/portal'])))); + return authenticationService + .checkIfUserIsAdministrator() + .pipe(map((isAdministrator) => (isAdministrator ? true : router.createUrlTree(['/portal'])))); }; }; diff --git a/src/Turnierplan.App/Client/src/app/portal/helpers/role-ids.ts b/src/Turnierplan.App/Client/src/app/portal/helpers/role-ids.ts deleted file mode 100644 index 40f6b82c..00000000 --- a/src/Turnierplan.App/Client/src/app/portal/helpers/role-ids.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class RoleIds { - public static readonly administratorRoleId: string = '9da7acec-ed66-4698-a2d6-927c9ee3f83a'; -} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html index 237f51bc..893ab60a 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html @@ -21,7 +21,7 @@ - + @@ -34,12 +34,8 @@ {{ user.createdAt | translateDate: 'medium' }} {{ user.lastPasswordChange | translateDate: 'medium' }} - @if (user.roles.length === 0) { - - } @else { - @for (role of user.roles; track role) { - - } + @if (user.isAdministrator) { + } diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts index be5c531b..ecbda077 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts @@ -8,7 +8,6 @@ import { AuthenticationService } from '../../../core/services/authentication.ser import { NotificationService } from '../../../core/services/notification.service'; import { PageFrameNavigationTab } from '../../components/page-frame/page-frame.component'; import { LoadingState } from '../../directives/loading-state/loading-state.directive'; -import { RoleIds } from '../../helpers/role-ids'; import { TitleService } from '../../services/title.service'; @Component({ @@ -55,15 +54,6 @@ export class AdministrationPageComponent implements OnInit { }); } - protected getRoleIcon(roleId: string): string { - switch (roleId) { - case RoleIds.administratorRoleId: - return 'bi-gear'; - default: - throw new Error(`Unknown role ID: ${roleId}`); - } - } - protected deleteButtonClicked(id: string, template: TemplateRef): void { if (id === this.currentUserId) { return; diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.html index cbc8d812..21020500 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.html @@ -2,7 +2,7 @@ @@ -31,7 +31,7 @@
-
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.ts index af4c902b..f0db816e 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/landing-page/landing-page.component.ts @@ -4,7 +4,6 @@ import { take } from 'rxjs'; import { OrganizationDto, OrganizationsService } from '../../../api'; import { PageFrameNavigationTab } from '../../components/page-frame/page-frame.component'; import { LoadingState } from '../../directives/loading-state/loading-state.directive'; -import { RoleIds } from '../../helpers/role-ids'; import { TitleService } from '../../services/title.service'; @Component({ @@ -12,8 +11,6 @@ import { TitleService } from '../../services/title.service'; templateUrl: './landing-page.component.html' }) export class LandingPageComponent implements OnInit { - protected readonly administratorRoleId = RoleIds.administratorRoleId; - protected loadingState: LoadingState = { isLoading: true }; protected organizations: OrganizationDto[] = []; diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.html b/src/Turnierplan.App/Client/src/app/portal/portal.component.html index fb8abe11..dab93820 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.html @@ -14,7 +14,7 @@
- +
diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts index 63567bc5..7206e567 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts @@ -5,8 +5,6 @@ import { Subject, takeUntil } from 'rxjs'; import { AuthenticatedUser } from '../core/models/identity'; import { AuthenticationService } from '../core/services/authentication.service'; -import { RoleIds } from './helpers/role-ids'; - type UserInfoAction = 'EditUserInfo' | 'ChangePassword' | 'Logout'; @Component({ @@ -15,8 +13,6 @@ type UserInfoAction = 'EditUserInfo' | 'ChangePassword' | 'Logout'; styleUrls: ['./portal.component.scss'] }) export class PortalComponent implements OnInit, OnDestroy { - protected readonly administratorRoleId = RoleIds.administratorRoleId; - protected currentUser?: AuthenticatedUser; protected footerStyle = ''; diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts index 8a2b609a..a5d534c2 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts @@ -55,10 +55,9 @@ import { ValidationErrorDialogComponent } from './components/validation-error-di import { VenueSelectComponent } from './components/venue-select/venue-select.component'; import { VenueTileComponent } from './components/venue-tile/venue-tile.component'; import { VisibilitySelectorComponent } from './components/visibility-selector/visibility-selector.component'; -import { HasRoleDirective } from './directives/has-role/has-role.directive'; +import { IsAdministratorDirective } from './directives/has-role/is-administrator.directive'; import { LoadingStateDirective } from './directives/loading-state/loading-state.directive'; -import { hasRoleGuard } from './guards/has-role.guard'; -import { RoleIds } from './helpers/role-ids'; +import { isAdministratorGuard } from './guards/is-administrator'; import { AdministrationPageComponent } from './pages/administration-page/administration-page.component'; import { ConfigureTournamentComponent } from './pages/configure-tournament/configure-tournament.component'; import { CreateApiKeyComponent } from './pages/create-api-key/create-api-key.component'; @@ -93,12 +92,12 @@ const routes: Routes = [ { path: 'administration', component: AdministrationPageComponent, - canActivate: [hasRoleGuard(RoleIds.administratorRoleId)] + canActivate: [isAdministratorGuard()] }, { path: 'administration/create/user', component: CreateUserComponent, - canActivate: [hasRoleGuard(RoleIds.administratorRoleId)] + canActivate: [isAdministratorGuard()] }, { path: 'create/organization', @@ -217,7 +216,7 @@ const routes: Routes = [ TournamentSelectComponent, MatchTreeComponent, FolderStatisticsComponent, - HasRoleDirective, + IsAdministratorDirective, AdministrationPageComponent, CreateUserComponent, BadgeComponent diff --git a/src/Turnierplan.App/Endpoints/EndpointBase.cs b/src/Turnierplan.App/Endpoints/EndpointBase.cs index dd1879a6..13f58b5d 100644 --- a/src/Turnierplan.App/Endpoints/EndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/EndpointBase.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.RegularExpressions; using Turnierplan.App.Security; -using Turnierplan.Core.User; namespace Turnierplan.App.Endpoints; @@ -27,7 +26,7 @@ internal abstract partial class EndpointBase protected virtual bool? AllowApiKeyAccess => null; - protected virtual Role[]? Roles => null; + protected virtual bool? RequireAdministrator => null; public void Map(IEndpointRouteBuilder endpoints) { @@ -52,9 +51,9 @@ private void ConfigureAuthorization(RouteHandlerBuilder builder) { if (DisableAuthorization) { - if (Roles is not null) + if (RequireAdministrator is not null) { - throw new InvalidOperationException($"Cannot define {nameof(Roles)} when {nameof(DisableAuthorization)} is {true}."); + throw new InvalidOperationException($"Cannot define {nameof(RequireAdministrator)} when {nameof(DisableAuthorization)} is {true}."); } if (AllowApiKeyAccess.HasValue) @@ -72,9 +71,9 @@ private void ConfigureAuthorization(RouteHandlerBuilder builder) policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type.Equals(ClaimTypes.OrganizationId) || x.Type.Equals(ClaimTypes.UserId))); - if (Roles is not null && Roles.Length > 0) + if (RequireAdministrator == true) { - policy.RequireRole(Roles.Select(role => role.Id.ToString())); + policy.RequireClaim(ClaimTypes.Administrator, "true"); } }); } diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 9ef3934a..9945f3ed 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -36,7 +36,12 @@ protected string CreateTokenForUser(User user, bool isRefreshToken) claims.Add(new Claim(ClaimTypes.DisplayName, user.Name)); claims.Add(new Claim(ClaimTypes.EMailAddress, user.EMail)); claims.Add(new Claim(ClaimTypes.UserId, user.Id.ToString())); - claims.AddRange(user.Roles.Select(x => new Claim(ClaimTypes.Role, x.Id.ToString()))); + + if (user.IsAdministrator) + { + // TODO: Add administrator claim once the new authorization policy is tested & works + // claims.Add(new Claim(ClaimTypes.Administrator, "true")); + } } var identityOptions = _options.CurrentValue; diff --git a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs index d1a725b9..af022b95 100644 --- a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs @@ -17,7 +17,7 @@ internal sealed class CreateUserEndpoint : EndpointBase protected override Delegate Handler => Handle; - protected override Role[] Roles => [UserRoles.Administrator]; + protected override bool? RequireAdministrator => true; private static async Task Handle( [FromBody] CreateUserEndpointRequest request, diff --git a/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs index 8e1e1cb5..da04ee28 100644 --- a/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs @@ -13,7 +13,7 @@ internal sealed class DeleteUserEndpoint : EndpointBase protected override Delegate Handler => Handle; - protected override Role[] Roles => [UserRoles.Administrator]; + protected override bool? RequireAdministrator => true; private static async Task Handle( [FromRoute] Guid id, diff --git a/src/Turnierplan.App/Endpoints/Users/GetUsersEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/GetUsersEndpoint.cs index 3ad1d4f9..b28d3762 100644 --- a/src/Turnierplan.App/Endpoints/Users/GetUsersEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/GetUsersEndpoint.cs @@ -12,7 +12,7 @@ internal sealed class GetUsersEndpoint : EndpointBase> protected override Delegate Handler => Handle; - protected override Role[] Roles => [UserRoles.Administrator]; + protected override bool? RequireAdministrator => true; private static async Task Handle( IUserRepository repository, diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 7ff30f29..d7b5fb1b 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -29,13 +29,13 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) const string initialEmail = "admin@example.com"; var initialPassword = Guid.NewGuid().ToString(); - // The available roles are inserted by the EFCore migration - var administratorRole = await context.Roles.Where(role => role.Id == UserRoles.Administrator.Id).SingleAsync().ConfigureAwait(false); - var passwordHasher = scope.ServiceProvider.GetRequiredService>(); - var initialUser = new User("Administrator", initialEmail); - initialUser.AddRole(administratorRole); + var initialUser = new User("Administrator", initialEmail) + { + IsAdministrator = true + }; + initialUser.UpdatePassword(passwordHasher.HashPassword(initialUser, initialPassword)); await context.Users.AddAsync(initialUser).ConfigureAwait(false); diff --git a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs index 2949505e..120f2516 100644 --- a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs @@ -14,11 +14,7 @@ protected override UserDto Map(IMapper mapper, MappingContext context, User sour Name = source.Name, EMail = source.EMail, LastPasswordChange = source.LastPasswordChange, - Roles = source.Roles.Select(role => new UserRoleDto - { - Id = role.Id, - Name = role.Name - }).ToArray() + IsAdministrator = source.IsAdministrator }; } } diff --git a/src/Turnierplan.App/Models/UserDto.cs b/src/Turnierplan.App/Models/UserDto.cs index f61ef454..caf371ee 100644 --- a/src/Turnierplan.App/Models/UserDto.cs +++ b/src/Turnierplan.App/Models/UserDto.cs @@ -12,5 +12,5 @@ public sealed record UserDto public required DateTime LastPasswordChange { get; init; } - public required UserRoleDto[] Roles { get; init; } + public required bool IsAdministrator { get; init; } } diff --git a/src/Turnierplan.App/Security/AccessValidator.cs b/src/Turnierplan.App/Security/AccessValidator.cs index 9a21da25..b1cffac2 100644 --- a/src/Turnierplan.App/Security/AccessValidator.cs +++ b/src/Turnierplan.App/Security/AccessValidator.cs @@ -18,7 +18,11 @@ public AccessValidator(IHttpContextAccessor contextAccessor) public bool CanSessionUserAccess(Organization organization) { - return _httpContext.User.HasClaim(ClaimTypes.UserId, organization.OwnerId.ToString()) - || _httpContext.User.HasClaim(ClaimTypes.OrganizationId, organization.Id.ToString()); + // TODO: Implement new access check :) + + return true; + + // return _httpContext.User.HasClaim(ClaimTypes.UserId, organization.OwnerId.ToString()) + // || _httpContext.User.HasClaim(ClaimTypes.OrganizationId, organization.Id.ToString()); } } diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index a90e6c04..a883b5a4 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -2,6 +2,7 @@ namespace Turnierplan.App.Security; internal static class ClaimTypes { + public const string Administrator = "adm"; public const string DisplayName = "name"; public const string EMailAddress = "mail"; public const string Role = "rol"; diff --git a/src/Turnierplan.Core/User/Role.cs b/src/Turnierplan.Core/User/Role.cs deleted file mode 100644 index 984715e1..00000000 --- a/src/Turnierplan.Core/User/Role.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Turnierplan.Core.SeedWork; - -namespace Turnierplan.Core.User; - -public sealed class Role : Entity // TODO: Delete -{ - internal Role(Guid id, string name) - { - Id = id; - Name = name; - } - - public override Guid Id { get; protected set; } - - public string Name { get; } -} diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 3e93580d..f3bd2500 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -1,4 +1,3 @@ -using Turnierplan.Core.Exceptions; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.User; @@ -6,7 +5,6 @@ namespace Turnierplan.Core.User; public sealed class User : Entity { internal readonly List _organizations = new(); - internal readonly List _roles = new(); public User(string name, string email) { @@ -18,11 +16,12 @@ public User(string name, string email) EMail = email; NormalizedEMail = NormalizeEmail(email); PasswordHash = string.Empty; + IsAdministrator = false; LastPasswordChange = DateTime.MinValue; SecurityStamp = Guid.Empty; } - internal User(Guid id, DateTime createdAt, string name, string eMail, string normalizedEMail, string passwordHash, DateTime lastPasswordChange, Guid securityStamp) + internal User(Guid id, DateTime createdAt, string name, string eMail, string normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) { Id = id; CreatedAt = createdAt; @@ -30,6 +29,7 @@ internal User(Guid id, DateTime createdAt, string name, string eMail, string nor EMail = eMail; NormalizedEMail = normalizedEMail; PasswordHash = passwordHash; + IsAdministrator = isAdministrator; LastPasswordChange = lastPasswordChange; SecurityStamp = securityStamp; } @@ -46,6 +46,8 @@ internal User(Guid id, DateTime createdAt, string name, string eMail, string nor public string PasswordHash { get; private set; } + public bool IsAdministrator { get; set; } + public DateTime LastPasswordChange { get; private set; } public DateTime? LastLogin { get; private set; } @@ -54,23 +56,6 @@ internal User(Guid id, DateTime createdAt, string name, string eMail, string nor public IReadOnlyList Organizations => _organizations.AsReadOnly(); - public IReadOnlyList Roles => _roles.AsReadOnly(); - - public void AddRole(Role role) - { - if (_roles.Any(x => x.Id == role.Id)) - { - throw new TurnierplanException($"Role {role.Id} is already assigned to the user."); - } - - _roles.Add(role); - } - - public void RemoveRole(Role role) - { - _roles.RemoveAll(x => x.Id == role.Id); - } - public void UpdateLastLoginTime() { LastLogin = DateTime.UtcNow; diff --git a/src/Turnierplan.Core/User/UserRoles.cs b/src/Turnierplan.Core/User/UserRoles.cs deleted file mode 100644 index 0e88321e..00000000 --- a/src/Turnierplan.Core/User/UserRoles.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Turnierplan.Core.User; - -public static class UserRoles -{ - public static readonly Role Administrator = new(Guid.Parse("9da7acec-ed66-4698-a2d6-927c9ee3f83a"), "Administrator"); -} diff --git a/src/Turnierplan.Dal/EntityConfigurations/RoleEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/RoleEntityTypeConfiguration.cs deleted file mode 100644 index 6196517e..00000000 --- a/src/Turnierplan.Dal/EntityConfigurations/RoleEntityTypeConfiguration.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Turnierplan.Core.User; - -namespace Turnierplan.Dal.EntityConfigurations; - -public sealed class RoleEntityTypeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Roles", TurnierplanContext.Schema); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Id) - .IsRequired(); - - builder.HasIndex(x => x.Name) - .IsUnique(); - - builder.Property(x => x.Name) - .IsRequired() - .HasMaxLength(ValidationConstants.Role.MaxNameLength); - - builder.HasData(UserRoles.Administrator); - } -} diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index dba015bf..398adf6c 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -46,11 +46,5 @@ public void Configure(EntityTypeBuilder builder) .WithOne() .HasForeignKey("OwnerId") .OnDelete(DeleteBehavior.Restrict); - - builder.HasMany(x => x.Roles) - .WithMany() - .UsingEntity("UserRoles", - r => r.HasOne(typeof(Role)).WithMany().HasForeignKey("RoleId"), - l => l.HasOne(typeof(User)).WithMany().HasForeignKey("UserId")); } } diff --git a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs index ffff3fb9..adf33525 100644 --- a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs +++ b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Turnierplan.Core.Organization; using Turnierplan.Core.PublicId; +using Turnierplan.Core.RoleAssignment; namespace Turnierplan.Dal.Repositories; @@ -42,6 +43,8 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos public Task> GetByOwnerUserIdAsync(Guid ownerUserId) { - return DbSet.Where(o => o.OwnerId == ownerUserId).ToListAsync(); + var userIdString = ownerUserId.ToString(); + + return DbSet.Where(o => o.RoleAssignments.Any(r => r.Principal.Kind == PrincipalKind.User && r.Principal.ObjectId.Equals(userIdString))).ToListAsync(); } } diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index 53723da4..38c3ecf5 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -8,7 +8,6 @@ internal sealed class UserRepository(TurnierplanContext context) : RepositoryBas public Task> GetAllUsers() { return DbSet - .Include(x => x.Roles) .ToListAsync(); } @@ -16,8 +15,6 @@ public Task> GetAllUsers() { var query = DbSet.Where(x => x.Id.Equals(id)); - query = query.Include(x => x.Roles); - if (includeOrganizationsDeep) { query = query.Include(x => x.Organizations).ThenInclude(x => x.Images); @@ -36,7 +33,6 @@ public Task> GetAllUsers() return DbSet .Where(x => x.NormalizedEMail.Equals(normalizedEMail)) - .Include(x => x.Roles) .FirstOrDefaultAsync(); } } diff --git a/src/Turnierplan.Dal/TurnierplanContext.cs b/src/Turnierplan.Dal/TurnierplanContext.cs index 99cdc7b8..1af8e1d9 100644 --- a/src/Turnierplan.Dal/TurnierplanContext.cs +++ b/src/Turnierplan.Dal/TurnierplanContext.cs @@ -12,7 +12,6 @@ using Turnierplan.Core.User; using Turnierplan.Core.Venue; using Turnierplan.Dal.EntityConfigurations; -using Role = Turnierplan.Core.User.Role; namespace Turnierplan.Dal; @@ -56,8 +55,6 @@ public TurnierplanContext(DbContextOptions options, ILogger< public DbSet> OrganizationRoleAssignments { get; set; } = null!; - public DbSet Roles { get; set; } = null!; - public DbSet Teams { get; set; } = null!; public DbSet Tournaments { get; set; } = null!; @@ -144,7 +141,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new ImageEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new MatchEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new OrganizationEntityTypeConfiguration()); - modelBuilder.ApplyConfiguration(new RoleEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new TeamEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new TournamentEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new UserEntityTypeConfiguration()); From 093ac8aa17da4c7d424e09dd011941abbeafa9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 13:43:10 +0200 Subject: [PATCH 03/13] Some fixes --- .../is-administrator.directive.ts | 0 .../Client/src/app/portal/portal.module.ts | 2 +- src/Turnierplan.App/Models/UserRoleDto.cs | 8 -------- src/Turnierplan.App/Security/ClaimTypes.cs | 1 - src/Turnierplan.App/Security/JwtAuthenticationHandler.cs | 3 +-- src/Turnierplan.Dal/ValidationConstants.cs | 5 ----- 6 files changed, 2 insertions(+), 17 deletions(-) rename src/Turnierplan.App/Client/src/app/portal/directives/{has-role => is-administrator}/is-administrator.directive.ts (100%) delete mode 100644 src/Turnierplan.App/Models/UserRoleDto.cs diff --git a/src/Turnierplan.App/Client/src/app/portal/directives/has-role/is-administrator.directive.ts b/src/Turnierplan.App/Client/src/app/portal/directives/is-administrator/is-administrator.directive.ts similarity index 100% rename from src/Turnierplan.App/Client/src/app/portal/directives/has-role/is-administrator.directive.ts rename to src/Turnierplan.App/Client/src/app/portal/directives/is-administrator/is-administrator.directive.ts diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts index a5d534c2..5e0a9990 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.module.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.module.ts @@ -55,7 +55,7 @@ import { ValidationErrorDialogComponent } from './components/validation-error-di import { VenueSelectComponent } from './components/venue-select/venue-select.component'; import { VenueTileComponent } from './components/venue-tile/venue-tile.component'; import { VisibilitySelectorComponent } from './components/visibility-selector/visibility-selector.component'; -import { IsAdministratorDirective } from './directives/has-role/is-administrator.directive'; +import { IsAdministratorDirective } from './directives/is-administrator/is-administrator.directive'; import { LoadingStateDirective } from './directives/loading-state/loading-state.directive'; import { isAdministratorGuard } from './guards/is-administrator'; import { AdministrationPageComponent } from './pages/administration-page/administration-page.component'; diff --git a/src/Turnierplan.App/Models/UserRoleDto.cs b/src/Turnierplan.App/Models/UserRoleDto.cs deleted file mode 100644 index 76418a2b..00000000 --- a/src/Turnierplan.App/Models/UserRoleDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Turnierplan.App.Models; - -public sealed record UserRoleDto -{ - public required Guid Id { get; init; } - - public required string Name { get; init; } -} diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index a883b5a4..1d22f1b2 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -5,7 +5,6 @@ internal static class ClaimTypes public const string Administrator = "adm"; public const string DisplayName = "name"; public const string EMailAddress = "mail"; - public const string Role = "rol"; public const string SecurityStamp = "sst"; public const string TokenType = "typ"; public const string UserId = "uid"; diff --git a/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs b/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs index 48035e00..f6e15f8c 100644 --- a/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs +++ b/src/Turnierplan.App/Security/JwtAuthenticationHandler.cs @@ -55,8 +55,7 @@ protected override Task HandleAuthenticateAsync() ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - ClockSkew = TimeSpan.Zero, - RoleClaimType = ClaimTypes.Role + ClockSkew = TimeSpan.Zero }; var claimsPrincipal = tokenHandler.ValidateToken(token, validationParameters, out _); diff --git a/src/Turnierplan.Dal/ValidationConstants.cs b/src/Turnierplan.Dal/ValidationConstants.cs index e7570da2..73cecc70 100644 --- a/src/Turnierplan.Dal/ValidationConstants.cs +++ b/src/Turnierplan.Dal/ValidationConstants.cs @@ -46,11 +46,6 @@ public static class Organization public const int MaxNameLength = 40; } - public static class Role - { - public const int MaxNameLength = 16; - } - public static class RoleAssignment { public const int MaxDescriptionLength = 250; From 8882443c26ad2d40108b00ba3103b2784291438a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 14:28:19 +0200 Subject: [PATCH 04/13] Add migration + some fixes --- .../Identity/IdentityEndpointBase.cs | 3 +- .../Organizations/GetOrganizationsEndpoint.cs | 5 +- .../Organization/IOrganizationRepository.cs | 6 +- .../20250615114355_Add_RBAC.Designer.cs | 1329 +++++++++++++++++ .../Migrations/20250615114355_Add_RBAC.cs | 330 ++++ .../TurnierplanContextModelSnapshot.cs | 343 ++++- .../Repositories/OrganizationRepository.cs | 11 +- 7 files changed, 1961 insertions(+), 66 deletions(-) create mode 100644 src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs create mode 100644 src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 9945f3ed..4d839bd5 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -39,8 +39,7 @@ protected string CreateTokenForUser(User user, bool isRefreshToken) if (user.IsAdministrator) { - // TODO: Add administrator claim once the new authorization policy is tested & works - // claims.Add(new Claim(ClaimTypes.Administrator, "true")); + claims.Add(new Claim(ClaimTypes.Administrator, "true")); } } diff --git a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs index a2bde6de..c320d444 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs @@ -2,6 +2,7 @@ using Turnierplan.App.Mapping; using Turnierplan.App.Models; using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; namespace Turnierplan.App.Endpoints.Organizations; @@ -20,7 +21,9 @@ private static async Task Handle( { var userId = context.GetCurrentUserIdOrThrow(); - var organizations = await repository.GetByOwnerUserIdAsync(userId).ConfigureAwait(false); + var principal = new Principal(PrincipalKind.User, userId.ToString()); + + var organizations = await repository.GetByPrincipalAsync(principal).ConfigureAwait(false); return Results.Ok(mapper.MapCollection(organizations)); } diff --git a/src/Turnierplan.Core/Organization/IOrganizationRepository.cs b/src/Turnierplan.Core/Organization/IOrganizationRepository.cs index 19c003e1..537f16d5 100644 --- a/src/Turnierplan.Core/Organization/IOrganizationRepository.cs +++ b/src/Turnierplan.Core/Organization/IOrganizationRepository.cs @@ -1,3 +1,4 @@ +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; namespace Turnierplan.Core.Organization; @@ -6,7 +7,10 @@ public interface IOrganizationRepository : IRepositoryWithPublicId GetByPublicIdAsync(PublicId.PublicId id, Include include); - Task> GetByOwnerUserIdAsync(Guid ownerUserId); + /// + /// Returns a list of all organizations that have any role assignment for the specified principal. + /// + Task> GetByPrincipalAsync(Principal principal); [Flags] public enum Include diff --git a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs b/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs new file mode 100644 index 00000000..f4b38b8d --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs @@ -0,0 +1,1329 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Turnierplan.Dal; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + [DbContext(typeof(TurnierplanContext))] + [Migration("20250615114355_Add_RBAC")] + partial class Add_RBAC + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecretHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("ApiKeys", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ApiKeyRequests", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GenerationCount") + .HasColumnType("integer"); + + b.Property("LastGeneration") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("TournamentId"); + + b.ToTable("Documents", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Folders", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("ResourceIdentifier") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ResourceIdentifier") + .IsUnique(); + + b.ToTable("Images", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Organizations", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AlphabeticalId") + .HasColumnType("character(1)"); + + b.Property("DisplayName") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Groups", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "GroupId", "TeamId"); + + b.HasIndex("TournamentId", "TeamId"); + + b.ToTable("GroupParticipants", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Court") + .HasColumnType("smallint"); + + b.Property("FinalsRound") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("IsCurrentlyPlaying") + .HasColumnType("boolean"); + + b.Property("Kickoff") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeType") + .HasColumnType("integer"); + + b.Property("PlayoffPosition") + .HasColumnType("integer"); + + b.Property("ScoreA") + .HasColumnType("integer"); + + b.Property("ScoreB") + .HasColumnType("integer"); + + b.Property("TeamSelectorA") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TeamSelectorB") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("TournamentId", "GroupId"); + + b.ToTable("Matches", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("EntryFeePaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("OutOfCompetition") + .HasColumnType("boolean"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Teams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("IsMigrated") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("OrganizerLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("PublicPageViews") + .HasColumnType("integer"); + + b.Property("SponsorBannerId") + .HasColumnType("bigint"); + + b.Property("SponsorLogoId") + .HasColumnType("bigint"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizerLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SponsorBannerId"); + + b.HasIndex("SponsorLogoId"); + + b.HasIndex("VenueId"); + + b.ToTable("Tournaments", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAdministrator") + .HasColumnType("boolean"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChange") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedEMail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEMail") + .IsUnique(); + + b.ToTable("Users", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection>("AddressDetails") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.PrimitiveCollection>("ExternalLinks") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Venues", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "ApiKey") + .WithMany("Requests") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Documents") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Folders") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Images") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.HasOne("Turnierplan.Core.User.User", null) + .WithMany("Organizations") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Groups") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany("Participants") + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithMany() + .HasForeignKey("TournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Matches") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany() + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Teams") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Folder") + .WithMany("Tournaments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Tournaments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "OrganizerLogo") + .WithMany() + .HasForeignKey("OrganizerLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SponsorBanner") + .WithMany() + .HasForeignKey("SponsorBannerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SponsorLogo") + .WithMany() + .HasForeignKey("SponsorLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Venue.Venue", "Venue") + .WithMany("Tournaments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("Turnierplan.Core.Tournament.ComputationConfiguration", "ComputationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("ComparisonModes") + .IsRequired() + .HasColumnType("integer[]") + .HasAnnotation("Relational:JsonPropertyName", "cmp"); + + b1.Property("MatchDrawnPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "d"); + + b1.Property("MatchLostPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "l"); + + b1.Property("MatchWonPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "w"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("ComputationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.MatchPlanConfiguration", "MatchPlanConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("MatchPlanConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.FinalsRoundConfig", "FinalsRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("EnableThirdPlacePlayoff") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "3rd"); + + b2.Property("FirstFinalsRoundOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "fo"); + + b2.PrimitiveCollection>("TeamSelectors") + .HasColumnType("text[]") + .HasAnnotation("Relational:JsonPropertyName", "ts"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "fr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + + b2.OwnsMany("Turnierplan.Core.Tournament.AdditionalPlayoffConfig", "AdditionalPlayoffs", b3 => + { + b3.Property("FinalsRoundConfigMatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b3.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b3.Property("PlayoffPosition") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b3.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "a"); + + b3.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "b"); + + b3.HasKey("FinalsRoundConfigMatchPlanConfigurationTournamentId", "__synthesizedOrdinal"); + + b3.ToTable("Tournaments", "turnierplan"); + + b3.HasAnnotation("Relational:JsonPropertyName", "ap"); + + b3.WithOwner() + .HasForeignKey("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + }); + + b2.Navigation("AdditionalPlayoffs"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.GroupRoundConfig", "GroupRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("GroupMatchOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b2.Property("GroupPhaseRounds") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "r"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "gr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.ScheduleConfig", "ScheduleConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("FinalsPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "fc"); + + b2.Property("FinalsPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fp"); + + b2.Property("FinalsPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fd"); + + b2.Property("FirstMatchKickoff") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "f"); + + b2.Property("GroupPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "gc"); + + b2.Property("GroupPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gp"); + + b2.Property("GroupPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gd"); + + b2.Property("PauseBetweenGroupAndFinalsPhase") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "sc"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.Navigation("FinalsRoundConfig"); + + b1.Navigation("GroupRoundConfig"); + + b1.Navigation("ScheduleConfig"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration", "PresentationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.Property("ShowOrganizerLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "ol"); + + b1.Property("ShowResults") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b1.Property("ShowSponsorLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "sl"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("PresentationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header1", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h1"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header2", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h2"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.Navigation("Header1") + .IsRequired(); + + b1.Navigation("Header2") + .IsRequired(); + }); + + b.Navigation("ComputationConfiguration") + .IsRequired(); + + b.Navigation("Folder"); + + b.Navigation("MatchPlanConfiguration"); + + b.Navigation("Organization"); + + b.Navigation("OrganizerLogo"); + + b.Navigation("PresentationConfiguration") + .IsRequired(); + + b.Navigation("SponsorBanner"); + + b.Navigation("SponsorLogo"); + + b.Navigation("Venue"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Venues") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Folders"); + + b.Navigation("Images"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + + b.Navigation("Venues"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Navigation("Documents"); + + b.Navigation("Groups"); + + b.Navigation("Matches"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Navigation("Organizations"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs b/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs new file mode 100644 index 00000000..7cbb4598 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs @@ -0,0 +1,330 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + /// + public partial class Add_RBAC : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserRoles", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "turnierplan"); + + migrationBuilder.AddColumn( + name: "IsAdministrator", + schema: "turnierplan", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AlterColumn( + name: "OwnerId", + schema: "turnierplan", + table: "Organizations", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.CreateTable( + name: "IAM_ApiKey", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ApiKeyId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_ApiKey", x => x.Id); + table.ForeignKey( + name: "FK_IAM_ApiKey_ApiKeys_ApiKeyId", + column: x => x.ApiKeyId, + principalSchema: "turnierplan", + principalTable: "ApiKeys", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IAM_Folder", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FolderId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_Folder", x => x.Id); + table.ForeignKey( + name: "FK_IAM_Folder_Folders_FolderId", + column: x => x.FolderId, + principalSchema: "turnierplan", + principalTable: "Folders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IAM_Image", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ImageId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_Image", x => x.Id); + table.ForeignKey( + name: "FK_IAM_Image_Images_ImageId", + column: x => x.ImageId, + principalSchema: "turnierplan", + principalTable: "Images", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IAM_Organization", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrganizationId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_Organization", x => x.Id); + table.ForeignKey( + name: "FK_IAM_Organization_Organizations_OrganizationId", + column: x => x.OrganizationId, + principalSchema: "turnierplan", + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IAM_Tournament", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TournamentId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_Tournament", x => x.Id); + table.ForeignKey( + name: "FK_IAM_Tournament_Tournaments_TournamentId", + column: x => x.TournamentId, + principalSchema: "turnierplan", + principalTable: "Tournaments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IAM_Venue", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + VenueId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Principal = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(250)", maxLength: 250, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IAM_Venue", x => x.Id); + table.ForeignKey( + name: "FK_IAM_Venue_Venues_VenueId", + column: x => x.VenueId, + principalSchema: "turnierplan", + principalTable: "Venues", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_IAM_ApiKey_ApiKeyId", + schema: "turnierplan", + table: "IAM_ApiKey", + column: "ApiKeyId"); + + migrationBuilder.CreateIndex( + name: "IX_IAM_Folder_FolderId", + schema: "turnierplan", + table: "IAM_Folder", + column: "FolderId"); + + migrationBuilder.CreateIndex( + name: "IX_IAM_Image_ImageId", + schema: "turnierplan", + table: "IAM_Image", + column: "ImageId"); + + migrationBuilder.CreateIndex( + name: "IX_IAM_Organization_OrganizationId", + schema: "turnierplan", + table: "IAM_Organization", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_IAM_Tournament_TournamentId", + schema: "turnierplan", + table: "IAM_Tournament", + column: "TournamentId"); + + migrationBuilder.CreateIndex( + name: "IX_IAM_Venue_VenueId", + schema: "turnierplan", + table: "IAM_Venue", + column: "VenueId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "IAM_ApiKey", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "IAM_Folder", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "IAM_Image", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "IAM_Organization", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "IAM_Tournament", + schema: "turnierplan"); + + migrationBuilder.DropTable( + name: "IAM_Venue", + schema: "turnierplan"); + + migrationBuilder.DropColumn( + name: "IsAdministrator", + schema: "turnierplan", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "OwnerId", + schema: "turnierplan", + table: "Organizations", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "Roles", + schema: "turnierplan", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(16)", maxLength: 16, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + schema: "turnierplan", + columns: table => new + { + RoleId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.RoleId, x.UserId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "turnierplan", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalSchema: "turnierplan", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + schema: "turnierplan", + table: "Roles", + columns: new[] { "Id", "Name" }, + values: new object[] { new Guid("9da7acec-ed66-4698-a2d6-927c9ee3f83a"), "Administrator" }); + + migrationBuilder.CreateIndex( + name: "IX_Roles_Name", + schema: "turnierplan", + table: "Roles", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_UserId", + schema: "turnierplan", + table: "UserRoles", + column: "UserId"); + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index ad6b2460..9def00e0 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("ProductVersion", "9.0.6") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -250,7 +250,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); - b.Property("OwnerId") + b.Property("OwnerId") .HasColumnType("uuid"); b.Property("PublicId") @@ -266,6 +266,204 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Organizations", "turnierplan"); }); + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => { b.Property("TournamentId") @@ -455,32 +653,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Tournaments", "turnierplan"); }); - modelBuilder.Entity("Turnierplan.Core.User.Role", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("character varying(16)"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("Roles", "turnierplan"); - - b.HasData( - new - { - Id = new Guid("9da7acec-ed66-4698-a2d6-927c9ee3f83a"), - Name = "Administrator" - }); - }); - modelBuilder.Entity("Turnierplan.Core.User.User", b => { b.Property("Id") @@ -495,6 +667,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("IsAdministrator") + .HasColumnType("boolean"); + b.Property("LastLogin") .HasColumnType("timestamp with time zone"); @@ -571,21 +746,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Venues", "turnierplan"); }); - modelBuilder.Entity("UserRoles", b => - { - b.Property("RoleId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("RoleId", "UserId"); - - b.HasIndex("UserId"); - - b.ToTable("UserRoles", "turnierplan"); - }); - modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => { b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") @@ -646,8 +806,73 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Turnierplan.Core.User.User", null) .WithMany("Organizations") .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Restrict) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); }); modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => @@ -1032,31 +1257,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("UserRoles", b => - { - b.HasOne("Turnierplan.Core.User.Role", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Turnierplan.Core.User.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => { b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); }); modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => { + b.Navigation("RoleAssignments"); + b.Navigation("Tournaments"); }); + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => { b.Navigation("ApiKeys"); @@ -1065,6 +1284,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Images"); + b.Navigation("RoleAssignments"); + b.Navigation("Tournaments"); b.Navigation("Venues"); @@ -1083,6 +1304,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Matches"); + b.Navigation("RoleAssignments"); + b.Navigation("Teams"); }); @@ -1093,6 +1316,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => { + b.Navigation("RoleAssignments"); + b.Navigation("Tournaments"); }); #pragma warning restore 612, 618 diff --git a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs index adf33525..e9e83ea8 100644 --- a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs +++ b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs @@ -41,10 +41,15 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos return query.FirstOrDefaultAsync(); } - public Task> GetByOwnerUserIdAsync(Guid ownerUserId) + /// + public Task> GetByPrincipalAsync(Principal principal) { - var userIdString = ownerUserId.ToString(); + // TODO: Try to optimize this query directly within EF - return DbSet.Where(o => o.RoleAssignments.Any(r => r.Principal.Kind == PrincipalKind.User && r.Principal.ObjectId.Equals(userIdString))).ToListAsync(); + return context.OrganizationRoleAssignments + .Where(r => r.Principal.Equals(principal)) + .Include(r => r.Scope) + .Select(r => r.Scope) + .ToListAsync(); } } From 269d9ca2a240a0b4a9e6df7c791810022df588b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 14:45:54 +0200 Subject: [PATCH 05/13] Remove user-org relationship --- .../CreateOrganizationEndpoint.cs | 6 ++- .../Endpoints/Users/DeleteUserEndpoint.cs | 2 +- src/Turnierplan.App/Helpers/DeletionHelper.cs | 24 +++++----- .../Organization/Organization.cs | 4 +- src/Turnierplan.Core/User/IUserRepository.cs | 2 +- src/Turnierplan.Core/User/User.cs | 4 -- .../UserEntityTypeConfiguration.cs | 5 -- ...cs => 20250615124413_Add_RBAC.Designer.cs} | 20 +------- ...Add_RBAC.cs => 20250615124413_Add_RBAC.cs} | 47 +++++++++++++------ .../TurnierplanContextModelSnapshot.cs | 18 ------- .../Repositories/UserRepository.cs | 15 +----- .../Renderer/MatchPlanRendererTest.cs | 4 +- .../Renderer/ReceiptsRendererTest.cs | 7 +-- .../Renderer/RefereeCardsRendererTest.cs | 4 +- 14 files changed, 61 insertions(+), 101 deletions(-) rename src/Turnierplan.Dal/Migrations/{20250615114355_Add_RBAC.Designer.cs => 20250615124413_Add_RBAC.Designer.cs} (98%) rename src/Turnierplan.Dal/Migrations/{20250615114355_Add_RBAC.cs => 20250615124413_Add_RBAC.cs} (93%) diff --git a/src/Turnierplan.App/Endpoints/Organizations/CreateOrganizationEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/CreateOrganizationEndpoint.cs index f71f58df..5208cc0e 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/CreateOrganizationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/CreateOrganizationEndpoint.cs @@ -3,7 +3,9 @@ using Turnierplan.App.Extensions; using Turnierplan.App.Mapping; using Turnierplan.App.Models; +using Turnierplan.Core.Extensions; using Turnierplan.Core.Organization; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.User; using Turnierplan.Dal; @@ -37,7 +39,9 @@ private static async Task Handle( return Results.Unauthorized(); } - var organization = new Organization(user, request.Name.Trim()); + var organization = new Organization(request.Name.Trim()); + + organization.AddRoleAssignment(Role.Owner, user.AsPrincipal()); await organizationRepository.CreateAsync(organization).ConfigureAwait(false); await organizationRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs index da04ee28..2110b097 100644 --- a/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/DeleteUserEndpoint.cs @@ -27,7 +27,7 @@ private static async Task Handle( return Results.BadRequest("You cannot delete yourself."); } - var user = await repository.GetByIdAsync(id, true).ConfigureAwait(false); + var user = await repository.GetByIdAsync(id).ConfigureAwait(false); if (user is null) { diff --git a/src/Turnierplan.App/Helpers/DeletionHelper.cs b/src/Turnierplan.App/Helpers/DeletionHelper.cs index bcd9bf68..66bef579 100644 --- a/src/Turnierplan.App/Helpers/DeletionHelper.cs +++ b/src/Turnierplan.App/Helpers/DeletionHelper.cs @@ -44,17 +44,19 @@ public DeletionHelper( public async Task DeleteUserAsync(User user, CancellationToken cancellationToken) { - foreach (var organization in user.Organizations.ToList()) // ToList() to avoid invalid operation exception - { - cancellationToken.ThrowIfCancellationRequested(); - - var result = await DeleteOrganizationAsync(organization, cancellationToken).ConfigureAwait(false); - - if (!result) - { - return false; - } - } + // TODO: Decide how to handle this (there is no longer a 1-n relation between user and organisation) + + // foreach (var organization in user.Organizations.ToList()) // ToList() to avoid invalid operation exception + // { + // cancellationToken.ThrowIfCancellationRequested(); + // + // var result = await DeleteOrganizationAsync(organization, cancellationToken).ConfigureAwait(false); + // + // if (!result) + // { + // return false; + // } + // } cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Turnierplan.Core/Organization/Organization.cs b/src/Turnierplan.Core/Organization/Organization.cs index c96c8d89..722cae13 100644 --- a/src/Turnierplan.Core/Organization/Organization.cs +++ b/src/Turnierplan.Core/Organization/Organization.cs @@ -12,10 +12,8 @@ public sealed class Organization : Entity, IEntityWithPublicId, IEntityWit internal readonly List _tournaments = new(); internal readonly List _venues = new(); - public Organization(User.User owner, string name) + public Organization(string name) { - owner._organizations.Add(this); - Id = 0; PublicId = new PublicId.PublicId(); CreatedAt = DateTime.UtcNow; diff --git a/src/Turnierplan.Core/User/IUserRepository.cs b/src/Turnierplan.Core/User/IUserRepository.cs index 6cd486b1..7b94f56a 100644 --- a/src/Turnierplan.Core/User/IUserRepository.cs +++ b/src/Turnierplan.Core/User/IUserRepository.cs @@ -6,7 +6,7 @@ public interface IUserRepository : IRepository { Task> GetAllUsers(); - Task GetByIdAsync(Guid id, bool includeOrganizationsDeep = false); + Task GetByIdAsync(Guid id); Task GetByEmailAsync(string email); } diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index f3bd2500..ab8d908f 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -4,8 +4,6 @@ namespace Turnierplan.Core.User; public sealed class User : Entity { - internal readonly List _organizations = new(); - public User(string name, string email) { email = email.Trim(); @@ -54,8 +52,6 @@ internal User(Guid id, DateTime createdAt, string name, string eMail, string nor public Guid SecurityStamp { get; private set; } - public IReadOnlyList Organizations => _organizations.AsReadOnly(); - public void UpdateLastLoginTime() { LastLogin = DateTime.UtcNow; diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index 398adf6c..56a15510 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -41,10 +41,5 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.SecurityStamp) .IsRequired(); - - builder.HasMany(x => x.Organizations) - .WithOne() - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Restrict); } } diff --git a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs similarity index 98% rename from src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs rename to src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs index f4b38b8d..cb307f87 100644 --- a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.Designer.cs +++ b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.Designer.cs @@ -13,7 +13,7 @@ namespace Turnierplan.Dal.Migrations { [DbContext(typeof(TurnierplanContext))] - [Migration("20250615114355_Add_RBAC")] + [Migration("20250615124413_Add_RBAC")] partial class Add_RBAC { /// @@ -253,16 +253,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); - b.Property("OwnerId") - .HasColumnType("uuid"); - b.Property("PublicId") .HasColumnType("bigint"); b.HasKey("Id"); - b.HasIndex("OwnerId"); - b.HasIndex("PublicId") .IsUnique(); @@ -804,14 +799,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => - { - b.HasOne("Turnierplan.Core.User.User", null) - .WithMany("Organizations") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Restrict); - }); - modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") @@ -1312,11 +1299,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Teams"); }); - modelBuilder.Entity("Turnierplan.Core.User.User", b => - { - b.Navigation("Organizations"); - }); - modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => { b.Navigation("RoleAssignments"); diff --git a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs similarity index 93% rename from src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs rename to src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs index 7cbb4598..42430064 100644 --- a/src/Turnierplan.Dal/Migrations/20250615114355_Add_RBAC.cs +++ b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs @@ -12,6 +12,11 @@ public partial class Add_RBAC : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.DropForeignKey( + name: "FK_Organizations_Users_OwnerId", + schema: "turnierplan", + table: "Organizations"); + migrationBuilder.DropTable( name: "UserRoles", schema: "turnierplan"); @@ -20,6 +25,16 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "Roles", schema: "turnierplan"); + migrationBuilder.DropIndex( + name: "IX_Organizations_OwnerId", + schema: "turnierplan", + table: "Organizations"); + + migrationBuilder.DropColumn( + name: "OwnerId", + schema: "turnierplan", + table: "Organizations"); + migrationBuilder.AddColumn( name: "IsAdministrator", schema: "turnierplan", @@ -28,15 +43,6 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: false); - migrationBuilder.AlterColumn( - name: "OwnerId", - schema: "turnierplan", - table: "Organizations", - type: "uuid", - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid"); - migrationBuilder.CreateTable( name: "IAM_ApiKey", schema: "turnierplan", @@ -256,16 +262,13 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "turnierplan", table: "Users"); - migrationBuilder.AlterColumn( + migrationBuilder.AddColumn( name: "OwnerId", schema: "turnierplan", table: "Organizations", type: "uuid", nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); migrationBuilder.CreateTable( name: "Roles", @@ -313,6 +316,12 @@ protected override void Down(MigrationBuilder migrationBuilder) columns: new[] { "Id", "Name" }, values: new object[] { new Guid("9da7acec-ed66-4698-a2d6-927c9ee3f83a"), "Administrator" }); + migrationBuilder.CreateIndex( + name: "IX_Organizations_OwnerId", + schema: "turnierplan", + table: "Organizations", + column: "OwnerId"); + migrationBuilder.CreateIndex( name: "IX_Roles_Name", schema: "turnierplan", @@ -325,6 +334,16 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "turnierplan", table: "UserRoles", column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Organizations_Users_OwnerId", + schema: "turnierplan", + table: "Organizations", + column: "OwnerId", + principalSchema: "turnierplan", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); } } } diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index 9def00e0..c975cf02 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -250,16 +250,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(40) .HasColumnType("character varying(40)"); - b.Property("OwnerId") - .HasColumnType("uuid"); - b.Property("PublicId") .HasColumnType("bigint"); b.HasKey("Id"); - b.HasIndex("OwnerId"); - b.HasIndex("PublicId") .IsUnique(); @@ -801,14 +796,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => - { - b.HasOne("Turnierplan.Core.User.User", null) - .WithMany("Organizations") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Restrict); - }); - modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => { b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") @@ -1309,11 +1296,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Teams"); }); - modelBuilder.Entity("Turnierplan.Core.User.User", b => - { - b.Navigation("Organizations"); - }); - modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => { b.Navigation("RoleAssignments"); diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index 38c3ecf5..3fb65403 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -11,20 +11,9 @@ public Task> GetAllUsers() .ToListAsync(); } - public Task GetByIdAsync(Guid id, bool includeOrganizationsDeep = false) + public Task GetByIdAsync(Guid id) { - var query = DbSet.Where(x => x.Id.Equals(id)); - - if (includeOrganizationsDeep) - { - query = query.Include(x => x.Organizations).ThenInclude(x => x.Images); - query = query.Include(x => x.Organizations).ThenInclude(x => x.Tournaments); - query = query.Include(x => x.Organizations).ThenInclude(x => x.Venues); - - query = query.AsSplitQuery(); - } - - return query.FirstOrDefaultAsync(); + return DbSet.Where(x => x.Id.Equals(id)).FirstOrDefaultAsync(); } public Task GetByEmailAsync(string email) diff --git a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/MatchPlanRendererTest.cs b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/MatchPlanRendererTest.cs index c9198680..d3149475 100644 --- a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/MatchPlanRendererTest.cs +++ b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/MatchPlanRendererTest.cs @@ -1,7 +1,6 @@ using Turnierplan.Core.Organization; using Turnierplan.Core.PublicId; using Turnierplan.Core.Tournament; -using Turnierplan.Core.User; using Turnierplan.PdfRendering.Configuration; using Turnierplan.PdfRendering.Renderer; @@ -103,8 +102,7 @@ public sealed class MatchPlanRendererTest(ITestOutputHelper testOutputHelper) : [MemberData(nameof(TestData))] public void MatchPlanRenderer___Render_Match_Plan___Works_As_Expected(int[] teamsPerGroup, MatchPlanConfiguration configuration) { - var user = new User("test", "test@test.com"); - var organization = new Organization(user, "Test"); + var organization = new Organization("Test"); var tournament = new Tournament(organization, "Test", Visibility.Public); for (var i = 0; i < teamsPerGroup.Length; i++) diff --git a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/ReceiptsRendererTest.cs b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/ReceiptsRendererTest.cs index ebf2e95f..cf46a177 100644 --- a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/ReceiptsRendererTest.cs +++ b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/ReceiptsRendererTest.cs @@ -1,6 +1,5 @@ using Turnierplan.Core.Organization; using Turnierplan.Core.Tournament; -using Turnierplan.Core.User; using Turnierplan.PdfRendering.Configuration; using Turnierplan.PdfRendering.Renderer; @@ -72,8 +71,7 @@ public void ReceiptsRenderer___Render_Receipts___Works_As_Expected() { for (var i = 4; i <= 16; i += 4) { - var user = new User("test", "test@test.com"); - var organization = new Organization(user, "Test"); + var organization = new Organization("Test"); var tournament = new Tournament(organization, "Test", Visibility.Public); for (var j = 0; j < i; j++) @@ -89,8 +87,7 @@ public void ReceiptsRenderer___Render_Receipts___Works_As_Expected() [MemberData(nameof(CombineTeamsTestData))] public void ReceiptsRenderer___GenerateReceipts___Returns_Correct_Result(string[] tournamentTeamNames, bool combineSimilar, (string Name, int Count)[] expectedCombinedTeams) { - var user = new User("test", "test@test.com"); - var organization = new Organization(user, "Test"); + var organization = new Organization("Test"); var tournament = new Tournament(organization, "Test", Visibility.Public); foreach (var teamName in tournamentTeamNames) diff --git a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/RefereeCardsRendererTest.cs b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/RefereeCardsRendererTest.cs index 449caa4e..151763a2 100644 --- a/src/Turnierplan.PdfRendering.Test.Unit/Renderer/RefereeCardsRendererTest.cs +++ b/src/Turnierplan.PdfRendering.Test.Unit/Renderer/RefereeCardsRendererTest.cs @@ -1,6 +1,5 @@ using Turnierplan.Core.Organization; using Turnierplan.Core.Tournament; -using Turnierplan.Core.User; using Turnierplan.PdfRendering.Configuration; using Turnierplan.PdfRendering.Renderer; @@ -13,8 +12,7 @@ public void RefereeCardsRenderer___Render_Referee_Cards___Works_As_Expected() { for (var i = 0; i < 3; i++) { - var user = new User("test", "test@test.com"); - var organization = new Organization(user, "Test"); + var organization = new Organization("Test"); var tournament = new Tournament(organization, "Test", Visibility.Public); var groupA = tournament.AddGroup('A'); From 988e9fde6ae3ca0c1b20d557484dcf12f799621c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 15:10:59 +0200 Subject: [PATCH 06/13] Add migration for owner -> IAM --- src/Turnierplan.Core/RoleAssignment/Role.cs | 12 +++++++--- .../Migrations/20250615124413_Add_RBAC.cs | 22 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.Core/RoleAssignment/Role.cs b/src/Turnierplan.Core/RoleAssignment/Role.cs index 769e348b..3e4b40f9 100644 --- a/src/Turnierplan.Core/RoleAssignment/Role.cs +++ b/src/Turnierplan.Core/RoleAssignment/Role.cs @@ -2,20 +2,26 @@ namespace Turnierplan.Core.RoleAssignment; public enum Role { + // Note: Don't change enum values (DB serialization) + + #region General Roles + /// /// This role grants all permissions on the target scope including the rights /// to delete the entity and modify role assignments. /// - Owner, + Owner = 1000, /// /// This role grants all permissions on the target scope excluding the rights /// to delete the entity and modify role assignments. /// - Contributor, + Contributor = 1001, /// /// This role grants the permission to view the target entity but not to make modifications. /// - Reader + Reader = 1002 + + #endregion } diff --git a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs index 42430064..ba987f69 100644 --- a/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs +++ b/src/Turnierplan.Dal/Migrations/20250615124413_Add_RBAC.cs @@ -30,10 +30,8 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", table: "Organizations"); - migrationBuilder.DropColumn( - name: "OwnerId", - schema: "turnierplan", - table: "Organizations"); + // The call to DropColumn() was generated here. + // See comment at the end of method migrationBuilder.AddColumn( name: "IsAdministrator", @@ -228,6 +226,22 @@ protected override void Up(MigrationBuilder migrationBuilder) schema: "turnierplan", table: "IAM_Venue", column: "VenueId"); + + // The DropColumn() call was moved here manually. This is done so that the + // corresponding role assignments can be created before the data is destroyed. + + // 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"), '' +FROM turnierplan."Organizations"; +"""); + + migrationBuilder.DropColumn( + name: "OwnerId", + schema: "turnierplan", + table: "Organizations"); } /// From 9800a612889b2535c980a76b614fcf8cdc6d7588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 15:32:27 +0200 Subject: [PATCH 07/13] New implementation of AccessVAlidator --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 2 +- src/Turnierplan.App/Endpoints/EndpointBase.cs | 2 +- .../Organizations/GetOrganizationsEndpoint.cs | 18 +++++--- .../Extensions/HttpContextExtensions.cs | 7 ++++ .../Security/AccessValidator.cs | 41 +++++++++++++++---- src/Turnierplan.App/Security/Actions.cs | 29 +++++++++++++ .../Security/ApiKeyAuthenticationHandler.cs | 2 +- src/Turnierplan.App/Security/ClaimTypes.cs | 2 +- .../Organization/IOrganizationRepository.cs | 2 + .../SeedWork/IEntityWithRoleAssignments.cs | 2 + .../Repositories/OrganizationRepository.cs | 5 +++ 11 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/Turnierplan.App/Security/Actions.cs diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index e9f5f760..4092bf61 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -94,7 +94,7 @@ export const de = { OrganizationCount: 'Organisationen' }, AdministratorWarning: - 'Sie sind mit einem Konto mit Administrator-Rolle angemeldet. Aktuell sehen Sie aber dennoch nur Organisationen, welche diesem Konto zugeordnet sind - jedoch nicht die Organisationen der anderen Benutzer.', + 'Sie sind mit einem Administrator-Konto angemeldet und haben unbeschränkten Zugriff auf alle Organisationen - auch die von anderen Benutzern.', NoOrganizations: 'Sie sind keinen Organisationen zugehörig.\nErstellen Sie eine neue Organisation, um Turniere anzulegen und zu bearbeiten', NewOrganization: 'Neue Organisation', diff --git a/src/Turnierplan.App/Endpoints/EndpointBase.cs b/src/Turnierplan.App/Endpoints/EndpointBase.cs index 13f58b5d..b01201da 100644 --- a/src/Turnierplan.App/Endpoints/EndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/EndpointBase.cs @@ -69,7 +69,7 @@ private void ConfigureAuthorization(RouteHandlerBuilder builder) ? [AuthenticationSchemes.AuthenticationSchemeApiKey, AuthenticationSchemes.AuthenticationSchemeSession] : [AuthenticationSchemes.AuthenticationSchemeSession]; - policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type.Equals(ClaimTypes.OrganizationId) || x.Type.Equals(ClaimTypes.UserId))); + policy.RequireAssertion(context => context.User.Claims.Any(x => x.Type.Equals(ClaimTypes.ApiKeyId) || x.Type.Equals(ClaimTypes.UserId))); if (RequireAdministrator == true) { diff --git a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs index c320d444..4f0c0dc0 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationsEndpoint.cs @@ -19,11 +19,19 @@ private static async Task Handle( IOrganizationRepository repository, IMapper mapper) { - var userId = context.GetCurrentUserIdOrThrow(); - - var principal = new Principal(PrincipalKind.User, userId.ToString()); - - var organizations = await repository.GetByPrincipalAsync(principal).ConfigureAwait(false); + List organizations; + + if (context.IsCurrentUserAdministrator()) + { + organizations = await repository.GetAllAsync().ConfigureAwait(false); + } + else + { + var userId = context.GetCurrentUserIdOrThrow(); + var principal = new Principal(PrincipalKind.User, userId.ToString()); + + organizations = await repository.GetByPrincipalAsync(principal).ConfigureAwait(false); + } return Results.Ok(mapper.MapCollection(organizations)); } diff --git a/src/Turnierplan.App/Extensions/HttpContextExtensions.cs b/src/Turnierplan.App/Extensions/HttpContextExtensions.cs index 9c9b7e6e..4990289c 100644 --- a/src/Turnierplan.App/Extensions/HttpContextExtensions.cs +++ b/src/Turnierplan.App/Extensions/HttpContextExtensions.cs @@ -15,4 +15,11 @@ public static Guid GetCurrentUserIdOrThrow(this HttpContext context) return userId; } + + public static bool IsCurrentUserAdministrator(this HttpContext context) + { + var claimValue = context.User.Claims.FirstOrDefault(x => x.Type.Equals(ClaimTypes.Administrator))?.Value; + + return !string.IsNullOrWhiteSpace(claimValue) && claimValue.Equals("true"); + } } diff --git a/src/Turnierplan.App/Security/AccessValidator.cs b/src/Turnierplan.App/Security/AccessValidator.cs index b1cffac2..419c9f2f 100644 --- a/src/Turnierplan.App/Security/AccessValidator.cs +++ b/src/Turnierplan.App/Security/AccessValidator.cs @@ -1,10 +1,13 @@ -using Turnierplan.Core.Organization; +using Turnierplan.App.Extensions; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Core.SeedWork; namespace Turnierplan.App.Security; internal interface IAccessValidator { - bool CanSessionUserAccess(Organization organization); + bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action) + where T : Entity, IEntityWithRoleAssignments; } internal sealed class AccessValidator : IAccessValidator @@ -16,13 +19,37 @@ public AccessValidator(IHttpContextAccessor contextAccessor) _httpContext = contextAccessor.HttpContext ?? throw new InvalidOperationException("Cannot access HttpContext"); } - public bool CanSessionUserAccess(Organization organization) + public bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action) + where T : Entity, IEntityWithRoleAssignments { - // TODO: Implement new access check :) + if (_httpContext.IsCurrentUserAdministrator()) + { + return true; + } - return true; + Principal? activePrincipal = null; - // return _httpContext.User.HasClaim(ClaimTypes.UserId, organization.OwnerId.ToString()) - // || _httpContext.User.HasClaim(ClaimTypes.OrganizationId, organization.Id.ToString()); + foreach (var claim in _httpContext.User.Claims) + { + if (claim.Type.Equals(ClaimTypes.ApiKeyId)) + { + activePrincipal = new Principal(PrincipalKind.ApiKey, claim.Value); + } + else if (claim.Type.Equals(ClaimTypes.UserId)) + { + activePrincipal = new Principal(PrincipalKind.User, claim.Value); + } + } + + if (activePrincipal is null) + { + throw new InvalidOperationException("Could not determine active principal."); + } + + var activePrincipalRoles = target.RoleAssignments + .Where(x => x.Principal.Equals(activePrincipal)) + .Select(x => x.Role); + + return action.IsAllowed(activePrincipalRoles); } } diff --git a/src/Turnierplan.App/Security/Actions.cs b/src/Turnierplan.App/Security/Actions.cs new file mode 100644 index 00000000..b184f37e --- /dev/null +++ b/src/Turnierplan.App/Security/Actions.cs @@ -0,0 +1,29 @@ +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.App.Security; + +internal static class Actions +{ + /// + /// Any action that reads or writes role assignments of some entity. + /// + public static readonly Action ReadOrWriteRoleAssignments = new(Role.Owner); + + /// + /// Any action that modifies some entity. + /// + public static readonly Action GenericWrite = new(Role.Owner, Role.Contributor); + + /// + /// Any action that reads information about some entity. + /// + public static readonly Action GenericRead = new(Role.Owner, Role.Contributor, Role.Reader); + + internal sealed class Action(params Role[] requiredRoles) + { + public bool IsAllowed(IEnumerable availableRoles) + { + return availableRoles.Any(requiredRoles.Contains); + } + } +} diff --git a/src/Turnierplan.App/Security/ApiKeyAuthenticationHandler.cs b/src/Turnierplan.App/Security/ApiKeyAuthenticationHandler.cs index d49ea5c8..6da34734 100644 --- a/src/Turnierplan.App/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Turnierplan.App/Security/ApiKeyAuthenticationHandler.cs @@ -79,7 +79,7 @@ protected override async Task HandleAuthenticateAsync() await _apiKeyRepository.UnitOfWork.SaveChangesAsync().ConfigureAwait(false); var identity = new ClaimsIdentity(claims: [ - new Claim(ClaimTypes.OrganizationId, apiKey.Organization.Id.ToString()) + new Claim(ClaimTypes.ApiKeyId, apiKey.Id.ToString()) ]); var principal = new ClaimsPrincipal([ identity ]); diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index 1d22f1b2..2712d03f 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -3,10 +3,10 @@ namespace Turnierplan.App.Security; internal static class ClaimTypes { public const string Administrator = "adm"; + public const string ApiKeyId = "turnierplan_api_key_id"; public const string DisplayName = "name"; public const string EMailAddress = "mail"; public const string SecurityStamp = "sst"; public const string TokenType = "typ"; public const string UserId = "uid"; - public const string OrganizationId = "turnierplan_org_id"; } diff --git a/src/Turnierplan.Core/Organization/IOrganizationRepository.cs b/src/Turnierplan.Core/Organization/IOrganizationRepository.cs index 537f16d5..4054f851 100644 --- a/src/Turnierplan.Core/Organization/IOrganizationRepository.cs +++ b/src/Turnierplan.Core/Organization/IOrganizationRepository.cs @@ -7,6 +7,8 @@ public interface IOrganizationRepository : IRepositoryWithPublicId GetByPublicIdAsync(PublicId.PublicId id, Include include); + Task> GetAllAsync(); + /// /// Returns a list of all organizations that have any role assignment for the specified principal. /// diff --git a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs index 1578dfd2..640e89f3 100644 --- a/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs +++ b/src/Turnierplan.Core/SeedWork/IEntityWithRoleAssignments.cs @@ -5,5 +5,7 @@ namespace Turnierplan.Core.SeedWork; public interface IEntityWithRoleAssignments where T : Entity, IEntityWithRoleAssignments { + IReadOnlyList> RoleAssignments { get; } + RoleAssignment AddRoleAssignment(Role role, Principal principal, string? description = null); } diff --git a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs index e9e83ea8..34ece6f1 100644 --- a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs +++ b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs @@ -41,6 +41,11 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos return query.FirstOrDefaultAsync(); } + public Task> GetAllAsync() + { + return DbSet.ToListAsync(); + } + /// public Task> GetByPrincipalAsync(Principal principal) { From f028b33393b35d32cb8c4332d4b2b299b5b17143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 15:51:29 +0200 Subject: [PATCH 08/13] Add recursive access check + update repos --- .../Security/AccessValidator.cs | 28 ++++++++++++++++++- .../Repositories/ApiKeyRepository.cs | 4 ++- .../Repositories/DocumentRepository.cs | 3 +- .../Repositories/FolderRepository.cs | 13 ++++----- .../Repositories/ImageRepository.cs | 4 ++- .../Repositories/OrganizationRepository.cs | 11 +++++++- .../Repositories/TournamentRepository.cs | 13 +++++++-- .../Repositories/VenueRepository.cs | 5 ++-- 8 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/Turnierplan.App/Security/AccessValidator.cs b/src/Turnierplan.App/Security/AccessValidator.cs index 419c9f2f..32dcb2a5 100644 --- a/src/Turnierplan.App/Security/AccessValidator.cs +++ b/src/Turnierplan.App/Security/AccessValidator.cs @@ -1,6 +1,11 @@ using Turnierplan.App.Extensions; +using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Folder; +using Turnierplan.Core.Image; using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.SeedWork; +using Turnierplan.Core.Tournament; +using Turnierplan.Core.Venue; namespace Turnierplan.App.Security; @@ -46,10 +51,31 @@ public bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Act throw new InvalidOperationException("Could not determine active principal."); } + return IsActionAllowed(target, action, activePrincipal); + } + + private static bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action, Principal activePrincipal) + where T : Entity, IEntityWithRoleAssignments + { var activePrincipalRoles = target.RoleAssignments .Where(x => x.Principal.Equals(activePrincipal)) .Select(x => x.Role); - return action.IsAllowed(activePrincipalRoles); + var isAccessAllowed = action.IsAllowed(activePrincipalRoles); + + if (isAccessAllowed) + { + return true; + } + + return target switch + { + ApiKey apiKey => IsActionAllowed(apiKey.Organization, action, activePrincipal), + Image image => IsActionAllowed(image.Organization, action, activePrincipal), + Folder folder => IsActionAllowed(folder.Organization, action, activePrincipal), + Tournament tournament => (tournament.Folder is not null && IsActionAllowed(tournament.Folder, action, activePrincipal)) || IsActionAllowed(tournament.Organization, action, activePrincipal), + Venue venue => IsActionAllowed(venue.Organization, action, activePrincipal), + _ => false + }; } } diff --git a/src/Turnierplan.Dal/Repositories/ApiKeyRepository.cs b/src/Turnierplan.Dal/Repositories/ApiKeyRepository.cs index 02f1a7dc..608f65d9 100644 --- a/src/Turnierplan.Dal/Repositories/ApiKeyRepository.cs +++ b/src/Turnierplan.Dal/Repositories/ApiKeyRepository.cs @@ -17,7 +17,9 @@ public ApiKeyRepository(TurnierplanContext context) public override Task GetByPublicIdAsync(PublicId id) { return DbSet.Where(x => x.PublicId == id) - .Include(x => x.Organization) + .Include(x => x.Organization).ThenInclude(x => x.RoleAssignments) + .Include(x => x.RoleAssignments) + .AsSplitQuery() .FirstOrDefaultAsync(); } diff --git a/src/Turnierplan.Dal/Repositories/DocumentRepository.cs b/src/Turnierplan.Dal/Repositories/DocumentRepository.cs index dca7e95f..b45b4930 100644 --- a/src/Turnierplan.Dal/Repositories/DocumentRepository.cs +++ b/src/Turnierplan.Dal/Repositories/DocumentRepository.cs @@ -10,7 +10,8 @@ internal sealed class DocumentRepository(TurnierplanContext context) : Repositor { var query = DbSet.Where(x => x.PublicId == id); - query = query.Include(x => x.Tournament).ThenInclude(x => x.Organization); + query = query.Include(x => x.Tournament).ThenInclude(x => x.Organization).ThenInclude(x => x.RoleAssignments); + query = query.Include(x => x.Tournament).ThenInclude(x => x.RoleAssignments); if (includeTournamentDetails) { diff --git a/src/Turnierplan.Dal/Repositories/FolderRepository.cs b/src/Turnierplan.Dal/Repositories/FolderRepository.cs index 45122d3d..a12a0368 100644 --- a/src/Turnierplan.Dal/Repositories/FolderRepository.cs +++ b/src/Turnierplan.Dal/Repositories/FolderRepository.cs @@ -9,7 +9,9 @@ internal sealed class FolderRepository(TurnierplanContext context) : RepositoryB public override Task GetByPublicIdAsync(PublicId id) { return DbSet.Where(x => x.PublicId == id) - .Include(x => x.Organization) + .Include(x => x.Organization).ThenInclude(x => x.RoleAssignments) + .Include(x => x.RoleAssignments) + .AsSplitQuery() .FirstOrDefaultAsync(); } @@ -17,7 +19,8 @@ internal sealed class FolderRepository(TurnierplanContext context) : RepositoryB { var query = DbSet.Where(x => x.PublicId == id); - query = query.Include(x => x.Organization); + query = query.Include(x => x.Organization).ThenInclude(x => x.RoleAssignments); + query = query.Include(x => x.RoleAssignments); if (include.HasFlag(IFolderRepository.Include.Tournaments)) { @@ -28,8 +31,6 @@ internal sealed class FolderRepository(TurnierplanContext context) : RepositoryB { query = query.Include(x => x.Tournaments).ThenInclude(x => x.Matches); query = query.Include(x => x.Tournaments).ThenInclude(x => x.Groups); - - query = query.AsSplitQuery(); } if (include.HasFlag(IFolderRepository.Include.TournamentsWithGameRelevant)) @@ -37,10 +38,8 @@ internal sealed class FolderRepository(TurnierplanContext context) : RepositoryB query = query.Include(x => x.Tournaments).ThenInclude(x => x.Matches); query = query.Include(x => x.Tournaments).ThenInclude(x => x.Groups); query = query.Include(x => x.Tournaments).ThenInclude(x => x.Teams); - - query = query.AsSplitQuery(); } - return await query.FirstOrDefaultAsync(); + return await query.AsSplitQuery().FirstOrDefaultAsync(); } } diff --git a/src/Turnierplan.Dal/Repositories/ImageRepository.cs b/src/Turnierplan.Dal/Repositories/ImageRepository.cs index 3d33e3c0..398f7aab 100644 --- a/src/Turnierplan.Dal/Repositories/ImageRepository.cs +++ b/src/Turnierplan.Dal/Repositories/ImageRepository.cs @@ -9,7 +9,9 @@ internal sealed class ImageRepository(TurnierplanContext context) : RepositoryBa public override Task GetByPublicIdAsync(PublicId id) { return DbSet.Where(x => x.PublicId == id) - .Include(x => x.Organization) + .Include(x => x.Organization).ThenInclude(x => x.RoleAssignments) + .Include(x => x.RoleAssignments) + .AsSplitQuery() .FirstOrDefaultAsync(); } } diff --git a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs index 34ece6f1..d6137650 100644 --- a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs +++ b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs @@ -7,6 +7,13 @@ namespace Turnierplan.Dal.Repositories; internal sealed class OrganizationRepository(TurnierplanContext context) : RepositoryBaseWithPublicId(context, context.Organizations), IOrganizationRepository { + public override Task GetByPublicIdAsync(PublicId id) + { + return DbSet.Where(x => x.PublicId == id) + .Include(x => x.RoleAssignments) + .FirstOrDefaultAsync(); + } + public Task GetByPublicIdAsync(PublicId id, IOrganizationRepository.Include include) { var query = DbSet.Where(o => o.PublicId == id); @@ -36,7 +43,9 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos query = query.Include(x => x.ApiKeys); } - query = query.AsSplitQuery(); + query = query + .Include(x => x.RoleAssignments) + .AsSplitQuery(); return query.FirstOrDefaultAsync(); } diff --git a/src/Turnierplan.Dal/Repositories/TournamentRepository.cs b/src/Turnierplan.Dal/Repositories/TournamentRepository.cs index aa838832..3ba7f40e 100644 --- a/src/Turnierplan.Dal/Repositories/TournamentRepository.cs +++ b/src/Turnierplan.Dal/Repositories/TournamentRepository.cs @@ -8,7 +8,12 @@ internal sealed class TournamentRepository(TurnierplanContext context) : Reposit { public override Task GetByPublicIdAsync(PublicId id) { - return DbSet.Where(x => x.PublicId == id).Include(x => x.Organization).FirstOrDefaultAsync(); + return DbSet.Where(x => x.PublicId == id) + .Include(x => x.Organization).ThenInclude(x => x.RoleAssignments) + .Include(x => x.Folder).ThenInclude(x => x!.RoleAssignments) + .Include(x => x.RoleAssignments) + .AsSplitQuery() + .FirstOrDefaultAsync(); } public async Task GetByPublicIdAsync(PublicId id, ITournamentRepository.Include include) @@ -43,10 +48,11 @@ 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); + query = query.Include(x => x.Folder).ThenInclude(x => x!.RoleAssignments); } if (include.HasFlag(ITournamentRepository.Include.Images)) @@ -56,7 +62,8 @@ internal sealed class TournamentRepository(TurnierplanContext context) : Reposit query = query.Include(x => x.SponsorBanner); } - query = query.Include(x => x.Organization); + query = query.Include(x => x.Organization).ThenInclude(x => x.RoleAssignments); + query = query.Include(x => x.RoleAssignments); query = query.AsSplitQuery(); diff --git a/src/Turnierplan.Dal/Repositories/VenueRepository.cs b/src/Turnierplan.Dal/Repositories/VenueRepository.cs index 0a00c0a8..254d64d8 100644 --- a/src/Turnierplan.Dal/Repositories/VenueRepository.cs +++ b/src/Turnierplan.Dal/Repositories/VenueRepository.cs @@ -8,9 +8,10 @@ internal sealed class VenueRepository(TurnierplanContext context) : RepositoryBa { public override Task GetByPublicIdAsync(PublicId id) { - // Always include organization to allow for checking authorization via its owner return DbSet.Where(x => x.PublicId == id) - .Include(x => x.Organization) + .Include(x => x.Organization).ThenInclude(x => x.RoleAssignments) + .Include(x => x.RoleAssignments) + .AsSplitQuery() .FirstOrDefaultAsync(); } } From 1f2a73209933eaceda4fbd894189f349c1249ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 15:51:37 +0200 Subject: [PATCH 09/13] Update security check in endpoints --- src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/ApiKeys/DeleteApiKeyEndpoint.cs | 2 +- .../Endpoints/ApiKeys/GetApiKeyUsageEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeysEndpoint.cs | 2 +- .../Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs | 2 +- .../Endpoints/Documents/CopyDocumentEndpoint.cs | 2 +- .../Endpoints/Documents/CreateDocumentEndpoint.cs | 2 +- .../Endpoints/Documents/DeleteDocumentEndpoint.cs | 2 +- .../Endpoints/Documents/GetDocumentConfigurationEndpoint.cs | 2 +- .../Endpoints/Documents/GetDocumentPdfEndpoint.cs | 2 +- .../Endpoints/Documents/GetDocumentsEndpoint.cs | 2 +- .../Endpoints/Documents/SetDocumentConfigurationEndpoint.cs | 2 +- .../Endpoints/Documents/SetDocumentNameEndpoint.cs | 2 +- .../Endpoints/Folders/GetFolderStatisticsEndpoint.cs | 2 +- .../Endpoints/Folders/GetFolderTimetableEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Folders/GetFoldersEndpoint.cs | 2 +- .../Endpoints/Folders/SetFolderNameEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Groups/SetGroupNameEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Images/DeleteImageEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs | 2 +- .../Endpoints/Matches/SetMatchOutcomeEndpoint.cs | 2 +- .../Endpoints/Organizations/DeleteOrganizationEndpoint.cs | 2 +- .../Endpoints/Organizations/GetOrganizationEndpoint.cs | 2 +- .../Endpoints/Organizations/SetOrganizationNameEndpoint.cs | 2 +- .../Endpoints/Teams/SetTeamEntryFeePaidEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Teams/SetTeamNameEndpoint.cs | 2 +- .../Endpoints/Teams/SetTeamOutOfCompetitionEndpoint.cs | 2 +- .../Endpoints/Teams/SetTeamPriorityEndpoint.cs | 2 +- .../Endpoints/Tournaments/ConfigureTournamentEndpoint.cs | 2 +- .../Endpoints/Tournaments/CreateTournamentEndpoint.cs | 2 +- .../Endpoints/Tournaments/DeleteTournamentEndpoint.cs | 2 +- .../Endpoints/Tournaments/GetTournamentEndpoint.cs | 2 +- .../Endpoints/Tournaments/GetTournamentImagesEndpoint.cs | 2 +- .../GetTournamentPresentationConfigurationEndpoint.cs | 2 +- .../Tournaments/GetTournamentTeamSelectorsEndpoint.cs | 2 +- .../Endpoints/Tournaments/GetTournamentsEndpoint.cs | 4 ++-- .../SetTournamentComputationConfigurationEndpoint.cs | 2 +- .../Endpoints/Tournaments/SetTournamentFolderEndpoint.cs | 4 ++-- .../Endpoints/Tournaments/SetTournamentImageEndpoint.cs | 4 ++-- .../Endpoints/Tournaments/SetTournamentMatchPlanEndpoint.cs | 2 +- .../Endpoints/Tournaments/SetTournamentNameEndpoint.cs | 2 +- .../SetTournamentPresentationConfigurationEndpoint.cs | 2 +- .../Endpoints/Tournaments/SetTournamentVenueEndpoint.cs | 4 ++-- .../Endpoints/Tournaments/SetTournamentVisibilityEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Venues/CreateVenueEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Venues/DeleteVenueEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Venues/GetVenueEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Venues/GetVenuesEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Venues/UpdateVenueEndpoint.cs | 2 +- 50 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs index b92a0750..3031fe4f 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs @@ -41,7 +41,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/DeleteApiKeyEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/DeleteApiKeyEndpoint.cs index d3bb27c2..7d31d642 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/DeleteApiKeyEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/DeleteApiKeyEndpoint.cs @@ -26,7 +26,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(apiKey.Organization)) + if (!accessValidator.IsActionAllowed(apiKey.Organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeyUsageEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeyUsageEndpoint.cs index 2bf56c11..a892789b 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeyUsageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeyUsageEndpoint.cs @@ -32,7 +32,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(apiKey.Organization)) + if (!accessValidator.IsActionAllowed(apiKey.Organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeysEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeysEndpoint.cs index 89eebd92..97142e4c 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeysEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/GetApiKeysEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs index f5813ccd..1f9cb117 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs @@ -27,7 +27,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(apiKey.Organization)) + if (!accessValidator.IsActionAllowed(apiKey.Organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/CopyDocumentEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/CopyDocumentEndpoint.cs index cbc35461..f26401b5 100644 --- a/src/Turnierplan.App/Endpoints/Documents/CopyDocumentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/CopyDocumentEndpoint.cs @@ -32,7 +32,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/CreateDocumentEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/CreateDocumentEndpoint.cs index c98abbbd..bf6709aa 100644 --- a/src/Turnierplan.App/Endpoints/Documents/CreateDocumentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/CreateDocumentEndpoint.cs @@ -48,7 +48,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/DeleteDocumentEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/DeleteDocumentEndpoint.cs index 0b153057..9b6d384a 100644 --- a/src/Turnierplan.App/Endpoints/Documents/DeleteDocumentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/DeleteDocumentEndpoint.cs @@ -26,7 +26,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(document.Tournament.Organization)) + if (!accessValidator.IsActionAllowed(document.Tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/GetDocumentConfigurationEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/GetDocumentConfigurationEndpoint.cs index 4af4622b..c79fed1d 100644 --- a/src/Turnierplan.App/Endpoints/Documents/GetDocumentConfigurationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/GetDocumentConfigurationEndpoint.cs @@ -55,7 +55,7 @@ private async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(document.Tournament.Organization)) + if (!accessValidator.IsActionAllowed(document.Tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/GetDocumentPdfEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/GetDocumentPdfEndpoint.cs index f179f880..4fc1f6e5 100644 --- a/src/Turnierplan.App/Endpoints/Documents/GetDocumentPdfEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/GetDocumentPdfEndpoint.cs @@ -52,7 +52,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(document.Tournament.Organization)) + if (!accessValidator.IsActionAllowed(document.Tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/GetDocumentsEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/GetDocumentsEndpoint.cs index cc17f350..fcd67a07 100644 --- a/src/Turnierplan.App/Endpoints/Documents/GetDocumentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/GetDocumentsEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/SetDocumentConfigurationEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/SetDocumentConfigurationEndpoint.cs index 2092d0dd..e27cd40e 100644 --- a/src/Turnierplan.App/Endpoints/Documents/SetDocumentConfigurationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/SetDocumentConfigurationEndpoint.cs @@ -49,7 +49,7 @@ private async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(document.Tournament.Organization)) + if (!accessValidator.IsActionAllowed(document.Tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Documents/SetDocumentNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Documents/SetDocumentNameEndpoint.cs index 0a6a042c..85505e67 100644 --- a/src/Turnierplan.App/Endpoints/Documents/SetDocumentNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Documents/SetDocumentNameEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(document.Tournament.Organization)) + if (!accessValidator.IsActionAllowed(document.Tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Folders/GetFolderStatisticsEndpoint.cs b/src/Turnierplan.App/Endpoints/Folders/GetFolderStatisticsEndpoint.cs index 6b696abc..e89e24d3 100644 --- a/src/Turnierplan.App/Endpoints/Folders/GetFolderStatisticsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Folders/GetFolderStatisticsEndpoint.cs @@ -27,7 +27,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(folder.Organization)) + if (!accessValidator.IsActionAllowed(folder, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Folders/GetFolderTimetableEndpoint.cs b/src/Turnierplan.App/Endpoints/Folders/GetFolderTimetableEndpoint.cs index 3250d859..32ccf7a7 100644 --- a/src/Turnierplan.App/Endpoints/Folders/GetFolderTimetableEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Folders/GetFolderTimetableEndpoint.cs @@ -26,7 +26,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(folder.Organization)) + if (!accessValidator.IsActionAllowed(folder, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Folders/GetFoldersEndpoint.cs b/src/Turnierplan.App/Endpoints/Folders/GetFoldersEndpoint.cs index eff44041..c8f7dce5 100644 --- a/src/Turnierplan.App/Endpoints/Folders/GetFoldersEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Folders/GetFoldersEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Folders/SetFolderNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Folders/SetFolderNameEndpoint.cs index bec23f69..4c9e7b47 100644 --- a/src/Turnierplan.App/Endpoints/Folders/SetFolderNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Folders/SetFolderNameEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(folder.Organization)) + if (!accessValidator.IsActionAllowed(folder, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Groups/SetGroupNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Groups/SetGroupNameEndpoint.cs index a3f8673b..9f266fa3 100644 --- a/src/Turnierplan.App/Endpoints/Groups/SetGroupNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Groups/SetGroupNameEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Images/DeleteImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/DeleteImageEndpoint.cs index eb5bce93..7db4165a 100644 --- a/src/Turnierplan.App/Endpoints/Images/DeleteImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/DeleteImageEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(image.Organization)) + if (!accessValidator.IsActionAllowed(image, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs index 17e83e52..ec639fbe 100644 --- a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs @@ -30,7 +30,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs index 35448f5f..a96a4961 100644 --- a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs @@ -51,7 +51,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Matches/SetMatchOutcomeEndpoint.cs b/src/Turnierplan.App/Endpoints/Matches/SetMatchOutcomeEndpoint.cs index c0214057..b279270b 100644 --- a/src/Turnierplan.App/Endpoints/Matches/SetMatchOutcomeEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Matches/SetMatchOutcomeEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Organizations/DeleteOrganizationEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/DeleteOrganizationEndpoint.cs index 4223ee59..e558a23f 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/DeleteOrganizationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/DeleteOrganizationEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationEndpoint.cs index 0aa44b17..54b594f8 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/GetOrganizationEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Organizations/SetOrganizationNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Organizations/SetOrganizationNameEndpoint.cs index 7eef944a..8dad8d10 100644 --- a/src/Turnierplan.App/Endpoints/Organizations/SetOrganizationNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Organizations/SetOrganizationNameEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Teams/SetTeamEntryFeePaidEndpoint.cs b/src/Turnierplan.App/Endpoints/Teams/SetTeamEntryFeePaidEndpoint.cs index 49f54369..ccf280bd 100644 --- a/src/Turnierplan.App/Endpoints/Teams/SetTeamEntryFeePaidEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Teams/SetTeamEntryFeePaidEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Teams/SetTeamNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Teams/SetTeamNameEndpoint.cs index 7af286ea..c68ec95d 100644 --- a/src/Turnierplan.App/Endpoints/Teams/SetTeamNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Teams/SetTeamNameEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Teams/SetTeamOutOfCompetitionEndpoint.cs b/src/Turnierplan.App/Endpoints/Teams/SetTeamOutOfCompetitionEndpoint.cs index 7492735b..2308694a 100644 --- a/src/Turnierplan.App/Endpoints/Teams/SetTeamOutOfCompetitionEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Teams/SetTeamOutOfCompetitionEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Teams/SetTeamPriorityEndpoint.cs b/src/Turnierplan.App/Endpoints/Teams/SetTeamPriorityEndpoint.cs index 4f882906..9d25dbba 100644 --- a/src/Turnierplan.App/Endpoints/Teams/SetTeamPriorityEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Teams/SetTeamPriorityEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/ConfigureTournamentEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/ConfigureTournamentEndpoint.cs index fc7cfad1..1426345d 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/ConfigureTournamentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/ConfigureTournamentEndpoint.cs @@ -38,7 +38,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/CreateTournamentEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/CreateTournamentEndpoint.cs index 514e5e6a..8eba841d 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/CreateTournamentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/CreateTournamentEndpoint.cs @@ -42,7 +42,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/DeleteTournamentEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/DeleteTournamentEndpoint.cs index 37bbe566..1ea6e3e5 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/DeleteTournamentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/DeleteTournamentEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs index 171e5d80..938bcc0a 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentEndpoint.cs @@ -30,7 +30,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentImagesEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentImagesEndpoint.cs index d3e967ce..4861ca6c 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentImagesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentImagesEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentPresentationConfigurationEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentPresentationConfigurationEndpoint.cs index 153cbb3d..3d0ff0b2 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentPresentationConfigurationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentPresentationConfigurationEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentTeamSelectorsEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentTeamSelectorsEndpoint.cs index 3c99de71..9bb92d9e 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentTeamSelectorsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentTeamSelectorsEndpoint.cs @@ -30,7 +30,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentsEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentsEndpoint.cs index be063728..085482b0 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentsEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/GetTournamentsEndpoint.cs @@ -43,7 +43,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } @@ -59,7 +59,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(folder.Organization)) + if (!accessValidator.IsActionAllowed(folder, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentComputationConfigurationEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentComputationConfigurationEndpoint.cs index d3c161b9..a1c0699e 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentComputationConfigurationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentComputationConfigurationEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentFolderEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentFolderEndpoint.cs index 8743babc..8fe20736 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentFolderEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentFolderEndpoint.cs @@ -37,7 +37,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } @@ -73,7 +73,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(folder.Organization)) + if (!accessValidator.IsActionAllowed(folder, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentImageEndpoint.cs index 6c3c6fac..ff6aa06d 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentImageEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } @@ -52,7 +52,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(image.Organization)) + if (!accessValidator.IsActionAllowed(image, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentMatchPlanEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentMatchPlanEndpoint.cs index 00cd3d3d..53fb898a 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentMatchPlanEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentMatchPlanEndpoint.cs @@ -37,7 +37,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentNameEndpoint.cs index a028d0f6..4e99a345 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentNameEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentPresentationConfigurationEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentPresentationConfigurationEndpoint.cs index 385c0338..b8f36ca3 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentPresentationConfigurationEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentPresentationConfigurationEndpoint.cs @@ -36,7 +36,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVenueEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVenueEndpoint.cs index 5f879dc9..e543b0f7 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVenueEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVenueEndpoint.cs @@ -29,7 +29,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } @@ -47,7 +47,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(venue.Organization)) + if (!accessValidator.IsActionAllowed(venue, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVisibilityEndpoint.cs b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVisibilityEndpoint.cs index 8f89d197..7a21e44b 100644 --- a/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVisibilityEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Tournaments/SetTournamentVisibilityEndpoint.cs @@ -34,7 +34,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(tournament.Organization)) + if (!accessValidator.IsActionAllowed(tournament, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Venues/CreateVenueEndpoint.cs b/src/Turnierplan.App/Endpoints/Venues/CreateVenueEndpoint.cs index 17555fb7..80f01aea 100644 --- a/src/Turnierplan.App/Endpoints/Venues/CreateVenueEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Venues/CreateVenueEndpoint.cs @@ -39,7 +39,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Venues/DeleteVenueEndpoint.cs b/src/Turnierplan.App/Endpoints/Venues/DeleteVenueEndpoint.cs index c2084b57..01266e0e 100644 --- a/src/Turnierplan.App/Endpoints/Venues/DeleteVenueEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Venues/DeleteVenueEndpoint.cs @@ -26,7 +26,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(venue.Organization)) + if (!accessValidator.IsActionAllowed(venue, Actions.GenericWrite)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Venues/GetVenueEndpoint.cs b/src/Turnierplan.App/Endpoints/Venues/GetVenueEndpoint.cs index 0438ee6f..4a95329d 100644 --- a/src/Turnierplan.App/Endpoints/Venues/GetVenueEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Venues/GetVenueEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(venue.Organization)) + if (!accessValidator.IsActionAllowed(venue, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Venues/GetVenuesEndpoint.cs b/src/Turnierplan.App/Endpoints/Venues/GetVenuesEndpoint.cs index d537153f..2695d33b 100644 --- a/src/Turnierplan.App/Endpoints/Venues/GetVenuesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Venues/GetVenuesEndpoint.cs @@ -28,7 +28,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(organization)) + if (!accessValidator.IsActionAllowed(organization, Actions.GenericRead)) { return Results.Forbid(); } diff --git a/src/Turnierplan.App/Endpoints/Venues/UpdateVenueEndpoint.cs b/src/Turnierplan.App/Endpoints/Venues/UpdateVenueEndpoint.cs index 4a7abd68..a83c67bf 100644 --- a/src/Turnierplan.App/Endpoints/Venues/UpdateVenueEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Venues/UpdateVenueEndpoint.cs @@ -35,7 +35,7 @@ private static async Task Handle( return Results.NotFound(); } - if (!accessValidator.CanSessionUserAccess(venue.Organization)) + if (!accessValidator.IsActionAllowed(venue, Actions.GenericWrite)) { return Results.Forbid(); } From d2c5e839c7ac28b85b15dcf285f901d8546c127c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 15:57:43 +0200 Subject: [PATCH 10/13] Add default auth scheme --- src/Turnierplan.App/Extensions/ServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Extensions/ServiceCollectionExtensions.cs b/src/Turnierplan.App/Extensions/ServiceCollectionExtensions.cs index 6fa0ddc2..f5fbd1c2 100644 --- a/src/Turnierplan.App/Extensions/ServiceCollectionExtensions.cs +++ b/src/Turnierplan.App/Extensions/ServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ public static void AddTurnierplanSecurity(this IServiceCollection services, ICon services.AddSingleton(); - services.AddAuthentication() + services.AddAuthentication(AuthenticationSchemes.AuthenticationSchemeSession) .AddScheme(AuthenticationSchemes.AuthenticationSchemeApiKey, _ => { }) .AddScheme(AuthenticationSchemes.AuthenticationSchemeSession, _ => { }); From 054e24f4a0973454fd98d9f29b18709065565b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 16:13:42 +0200 Subject: [PATCH 11/13] Add role assignment for API key --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 2 +- .../Endpoints/ApiKeys/CreateApiKeyEndpoint.cs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 4092bf61..55774f4a 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -868,7 +868,7 @@ export const de = { Validity180: '180 Tage' }, OrganizationNotice: 'Es wird ein neuer API-Schlüssel in der Organisation {{organizationName}} angelegt.', - AccessNotice: 'Mit diesem Schlüssel kann auf alle Turniere der Organisation zugegriffen werden', + AccessNotice: 'Ein neuer API-Schlüssel erhält standardmäßig Leserechte für die aktuelle Organisation.', Submit: 'Erstellen', SuccessInformation: { Title: 'API-Schlüssel wurde erstellt', diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs index 3031fe4f..39226a89 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/CreateApiKeyEndpoint.cs @@ -6,9 +6,12 @@ using Turnierplan.App.Models; using Turnierplan.App.Security; using Turnierplan.Core.ApiKey; +using Turnierplan.Core.Extensions; using Turnierplan.Core.Organization; using Turnierplan.Core.PublicId; +using Turnierplan.Core.RoleAssignment; using Turnierplan.Dal; +using Turnierplan.Dal.Extensions; namespace Turnierplan.App.Endpoints.ApiKeys; @@ -52,7 +55,19 @@ private static async Task Handle( apiKey.AssignNewSecret(plainText => secretHasher.HashPassword(apiKey, plainText), out var secret); await apiKeyRepository.CreateAsync(apiKey).ConfigureAwait(false); - await apiKeyRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + await using (var transaction = await apiKeyRepository.UnitOfWork.WrapTransactionAsync().ConfigureAwait(false)) + { + // Save changes to generate IDs for the api key + await apiKeyRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // Once the api key has an ID, the role assignment can be created + organization.AddRoleAssignment(Role.Reader, apiKey.AsPrincipal()); + + await apiKeyRepository.UnitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + transaction.ShouldCommit = true; + } return Results.Ok(mapper.Map(apiKey) with { Secret = secret }); } From c31f3e7e95cd32816bd563563f1f162772ec459e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 16:33:23 +0200 Subject: [PATCH 12/13] Add tests and fix some issues --- .../Security/AccessValidatorTest.cs | 84 +++++++++++++++++++ .../Security/ActionsTest.cs | 26 ++++++ .../Extensions/HttpContextExtensions.cs | 20 +++++ .../Security/AccessValidator.cs | 35 ++------ src/Turnierplan.Core/Image/Image.cs | 1 + 5 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs create mode 100644 src/Turnierplan.App.Test.Unit/Security/ActionsTest.cs diff --git a/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs new file mode 100644 index 00000000..52bb9ae1 --- /dev/null +++ b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs @@ -0,0 +1,84 @@ +using Turnierplan.App.Security; +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.Test.Unit.Security; + +public sealed class AccessValidatorTest +{ + [Fact] + public void IsActionAllowed___When_Called_With_Basic_Target___Returns_Expected_Result() + { + var target = new Organization("Test"); + + var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130"); + var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51"); + + target.AddRoleAssignment(Role.Reader, principal); + target.AddRoleAssignment(Role.Contributor, otherPrincipal); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse(); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue(); + } + + [Fact] + public void IsActionAllowed___When_Called_With_Indirect_Target___Returns_Expected_Result() + { + var organization = new Organization("Test"); + + var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130"); + var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51"); + + organization.AddRoleAssignment(Role.Reader, principal); + organization.AddRoleAssignment(Role.Contributor, otherPrincipal); + + void Test(Func factory) + where T : Entity, IEntityWithRoleAssignments + { + var target = factory(); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse(); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue(); + } + + Test(() => new ApiKey(organization, "Test", null, DateTime.MaxValue)); + Test(() => new Image(organization, "Test", ImageType.SquareLargeLogo, "", 0, 1, 1)); + Test(() => new Folder(organization, "Test")); + Test(() => new Tournament(organization, "Test", Visibility.Public)); + Test(() => new Venue(organization, "Test", "")); + } + + [Fact] + public void IsActionAllowed___When_Called_With_Tournament_Target_And_Role_Assignment_On_Folder___Returns_Expected_Result() + { + var organization = new Organization("Test"); + var folder = new Folder(organization, "Test"); + + var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130"); + var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51"); + + folder.AddRoleAssignment(Role.Reader, principal); + folder.AddRoleAssignment(Role.Contributor, otherPrincipal); + + var target = new Tournament(organization, "Test", Visibility.Public); + target.SetFolder(folder); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse(); + + AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue(); + AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue(); + } +} diff --git a/src/Turnierplan.App.Test.Unit/Security/ActionsTest.cs b/src/Turnierplan.App.Test.Unit/Security/ActionsTest.cs new file mode 100644 index 00000000..4234b324 --- /dev/null +++ b/src/Turnierplan.App.Test.Unit/Security/ActionsTest.cs @@ -0,0 +1,26 @@ +using Turnierplan.App.Security; +using Turnierplan.Core.RoleAssignment; + +namespace Turnierplan.App.Test.Unit.Security; + +public sealed class ActionsTest +{ + [Fact] + public void IsAllowed___When_Called_With_Various_Roles___Returns_Correct_Value() + { + Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Owner]).Should().BeTrue(); + Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Contributor]).Should().BeFalse(); + Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Reader]).Should().BeFalse(); + + Actions.GenericWrite.IsAllowed([Role.Owner]).Should().BeTrue(); + Actions.GenericWrite.IsAllowed([Role.Contributor]).Should().BeTrue(); + Actions.GenericWrite.IsAllowed([Role.Reader]).Should().BeFalse(); + + Actions.GenericRead.IsAllowed([Role.Owner]).Should().BeTrue(); + Actions.GenericRead.IsAllowed([Role.Contributor]).Should().BeTrue(); + Actions.GenericRead.IsAllowed([Role.Reader]).Should().BeTrue(); + + Actions.GenericWrite.IsAllowed([Role.Reader, Role.Contributor]).Should().BeTrue(); + Actions.GenericWrite.IsAllowed([Role.Reader]).Should().BeFalse(); + } +} diff --git a/src/Turnierplan.App/Extensions/HttpContextExtensions.cs b/src/Turnierplan.App/Extensions/HttpContextExtensions.cs index 4990289c..b73f452a 100644 --- a/src/Turnierplan.App/Extensions/HttpContextExtensions.cs +++ b/src/Turnierplan.App/Extensions/HttpContextExtensions.cs @@ -1,4 +1,5 @@ using Turnierplan.App.Security; +using Turnierplan.Core.RoleAssignment; namespace Turnierplan.App.Extensions; @@ -22,4 +23,23 @@ public static bool IsCurrentUserAdministrator(this HttpContext context) return !string.IsNullOrWhiteSpace(claimValue) && claimValue.Equals("true"); } + + public static Principal GetActivePrincipal(this HttpContext context) + { + foreach (var claim in context.User.Claims) + { + if (claim.Type.Equals(ClaimTypes.ApiKeyId)) + { + return new Principal(PrincipalKind.ApiKey, claim.Value); + } + + if (claim.Type.Equals(ClaimTypes.UserId)) + { + return new Principal(PrincipalKind.User, claim.Value); + } + } + + throw new InvalidOperationException("Could not determine active principal."); + } + } diff --git a/src/Turnierplan.App/Security/AccessValidator.cs b/src/Turnierplan.App/Security/AccessValidator.cs index 32dcb2a5..9aea835d 100644 --- a/src/Turnierplan.App/Security/AccessValidator.cs +++ b/src/Turnierplan.App/Security/AccessValidator.cs @@ -32,33 +32,16 @@ public bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Act return true; } - Principal? activePrincipal = null; + var principal = _httpContext.GetActivePrincipal(); - foreach (var claim in _httpContext.User.Claims) - { - if (claim.Type.Equals(ClaimTypes.ApiKeyId)) - { - activePrincipal = new Principal(PrincipalKind.ApiKey, claim.Value); - } - else if (claim.Type.Equals(ClaimTypes.UserId)) - { - activePrincipal = new Principal(PrincipalKind.User, claim.Value); - } - } - - if (activePrincipal is null) - { - throw new InvalidOperationException("Could not determine active principal."); - } - - return IsActionAllowed(target, action, activePrincipal); + return IsActionAllowed(target, action, principal); } - private static bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action, Principal activePrincipal) + internal static bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action, Principal principal) where T : Entity, IEntityWithRoleAssignments { var activePrincipalRoles = target.RoleAssignments - .Where(x => x.Principal.Equals(activePrincipal)) + .Where(x => x.Principal.Equals(principal)) .Select(x => x.Role); var isAccessAllowed = action.IsAllowed(activePrincipalRoles); @@ -70,11 +53,11 @@ private static bool IsActionAllowed(IEntityWithRoleAssignments target, Act return target switch { - ApiKey apiKey => IsActionAllowed(apiKey.Organization, action, activePrincipal), - Image image => IsActionAllowed(image.Organization, action, activePrincipal), - Folder folder => IsActionAllowed(folder.Organization, action, activePrincipal), - Tournament tournament => (tournament.Folder is not null && IsActionAllowed(tournament.Folder, action, activePrincipal)) || IsActionAllowed(tournament.Organization, action, activePrincipal), - Venue venue => IsActionAllowed(venue.Organization, action, activePrincipal), + ApiKey apiKey => IsActionAllowed(apiKey.Organization, action, principal), + Image image => IsActionAllowed(image.Organization, action, principal), + Folder folder => IsActionAllowed(folder.Organization, action, principal), + Tournament tournament => (tournament.Folder is not null && IsActionAllowed(tournament.Folder, action, principal)) || IsActionAllowed(tournament.Organization, action, principal), + Venue venue => IsActionAllowed(venue.Organization, action, principal), _ => false }; } diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index 55a6f9e0..d2578226 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -17,6 +17,7 @@ public Image(Organization.Organization organization, string name, ImageType type Id = 0; ResourceIdentifier = Guid.NewGuid(); PublicId = new PublicId.PublicId(); + Organization = organization; CreatedAt = DateTime.UtcNow; Name = name; Type = type; From cb876e386eda979c374bfac1f6ed8c4109a6aa9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Jun 2025 16:49:08 +0200 Subject: [PATCH 13/13] Add test --- .../Converters/PrincipalConverterTest.cs | 45 +++++++++++++++++++ src/Turnierplan.Dal/AssemblyInfo.cs | 3 ++ .../Converters/RoleAssignmentConverter.cs | 6 +-- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/Turnierplan.Dal.Test.Unit/Converters/PrincipalConverterTest.cs create mode 100644 src/Turnierplan.Dal/AssemblyInfo.cs diff --git a/src/Turnierplan.Dal.Test.Unit/Converters/PrincipalConverterTest.cs b/src/Turnierplan.Dal.Test.Unit/Converters/PrincipalConverterTest.cs new file mode 100644 index 00000000..f9792f9f --- /dev/null +++ b/src/Turnierplan.Dal.Test.Unit/Converters/PrincipalConverterTest.cs @@ -0,0 +1,45 @@ +using Turnierplan.Core.Exceptions; +using Turnierplan.Core.RoleAssignment; +using Turnierplan.Dal.Converters; + +namespace Turnierplan.Dal.Test.Unit.Converters; + +public sealed class PrincipalConverterTest +{ + [Theory] + [InlineData(PrincipalKind.ApiKey, "123", "ApiKey:123")] + [InlineData(PrincipalKind.User, "2e839c1b-04ea-43c9-9bd1-614bf9586859", "User:2e839c1b-04ea-43c9-9bd1-614bf9586859")] + public void FormatPrincipal___When_Called___Produces_Expected_Result(PrincipalKind kind, string objectId, string expectedResult) + { + var principal = new Principal(kind, objectId); + + var result = PrincipalConverter.FormatPrincipal(principal); + + result.Should().Be(expectedResult); + } + + [Theory] + [InlineData("ApiKey:123", PrincipalKind.ApiKey, "123")] + [InlineData("User:2e839c1b-04ea-43c9-9bd1-614bf9586859", PrincipalKind.User, "2e839c1b-04ea-43c9-9bd1-614bf9586859")] + public void ParsePrincipal___When_Called_With_Valid_String___Returns_Expected_Result(string representation, PrincipalKind expectedKind, string expectedObjectId) + { + var result = PrincipalConverter.ParsePrincipal(representation); + + result.Kind.Should().Be(expectedKind); + result.ObjectId.Should().Be(expectedObjectId); + } + + [Theory] + [InlineData("ApiKey:")] + [InlineData("ApiKey:123 ")] + [InlineData(" ApiKey:123")] + [InlineData("ApiKey:4285231c-cd63-4eb5-adb5-1604d00b2e8e")] + [InlineData("User:123")] + [InlineData("User:4285231c-cd63-4eb5-adb5-1604d00b2e8")] + public void ParsePrincipal___When_Called_With_Invalid_String___Throws_Exception(string representation) + { + var action = () => PrincipalConverter.ParsePrincipal(representation); + + action.Should().ThrowExactly().WithMessage("Invalid principal string."); + } +} diff --git a/src/Turnierplan.Dal/AssemblyInfo.cs b/src/Turnierplan.Dal/AssemblyInfo.cs new file mode 100644 index 00000000..8414f664 --- /dev/null +++ b/src/Turnierplan.Dal/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Turnierplan.Dal.Test.Unit")] diff --git a/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs b/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs index b2cee0af..a73302d8 100644 --- a/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs +++ b/src/Turnierplan.Dal/Converters/RoleAssignmentConverter.cs @@ -12,12 +12,12 @@ public PrincipalConverter() { } - private static string FormatPrincipal(Principal principal) + internal static string FormatPrincipal(Principal principal) { return $"{principal.Kind}:{principal.ObjectId}"; } - private static Principal ParsePrincipal(string input) + internal static Principal ParsePrincipal(string input) { var match = PrincipalRegex().Match(input); @@ -29,6 +29,6 @@ private static Principal ParsePrincipal(string input) return new Principal(kind, match.Groups["ObjectId"].Value); } - [GeneratedRegex("^(?:(?ApiKey):(?\\d+))|(?:(?User):(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))$")] + [GeneratedRegex(@"^(?:(?ApiKey):(?\d+))$|^(?:(?User):(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))$")] private static partial Regex PrincipalRegex(); }