diff --git a/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionCreationHandler.cs b/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionCreationHandler.cs index b04fbd2..8a26dbb 100644 --- a/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionCreationHandler.cs +++ b/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionCreationHandler.cs @@ -1,11 +1,18 @@ namespace HttpsRichardy.Federation.Application.Handlers.Permission; -public sealed class PermissionCreationHandler(IPermissionCollection collection, IRealmProvider realmProvider) : +public sealed class PermissionCreationHandler(IPermissionCollection collection, IPermissionNamespacePolicy policy, IRealmProvider realmProvider) : IDispatchHandler> { public async Task> HandleAsync(PermissionCreationScheme parameters, CancellationToken cancellation = default) { var realm = realmProvider.GetCurrentRealm(); + var result = await policy.EnsurePermissionIsAllowedAsync(realm, new() { Name = parameters.Name }, cancellation); + + if (result.IsFailure) + { + return Result.Failure(PermissionErrors.PermissionNameIsReserved); + } + var filters = PermissionFilters.WithSpecifications() .WithName(parameters.Name) .Build(); @@ -23,4 +30,4 @@ public async Task> HandleAsync(PermissionCreatio return Result.Success(PermissionMapper.AsResponse(createdPermission)); } -} \ No newline at end of file +} diff --git a/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionUpdateHandler.cs b/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionUpdateHandler.cs index 89a6af9..cea626e 100644 --- a/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionUpdateHandler.cs +++ b/Source/HttpsRichardy.Federation.Application/Handlers/Permission/PermissionUpdateHandler.cs @@ -1,6 +1,6 @@ namespace HttpsRichardy.Federation.Application.Handlers.Permission; -public sealed class PermissionUpdateHandler(IPermissionCollection collection, IRealmProvider realmProvider) : +public sealed class PermissionUpdateHandler(IPermissionCollection collection, IPermissionNamespacePolicy policy, IRealmProvider realmProvider) : IDispatchHandler> { public async Task> HandleAsync(PermissionUpdateScheme parameters, CancellationToken cancellation = default) @@ -18,10 +18,16 @@ public async Task> HandleAsync(PermissionUpdateS return Result.Failure(PermissionErrors.PermissionDoesNotExist); } + var result = await policy.EnsurePermissionIsAllowedAsync(realm, new() { Name = parameters.Name }, cancellation); + if (result.IsFailure) + { + return Result.Failure(PermissionErrors.PermissionNameIsReserved); + } + permission = PermissionMapper.AsPermission(parameters, permission, realm); var updatedPermission = await collection.UpdateAsync(permission, cancellation: cancellation); return Result.Success(PermissionMapper.AsResponse(updatedPermission)); } -} \ No newline at end of file +} diff --git a/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs b/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs index d8467ba..263e4f0 100644 --- a/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs +++ b/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs @@ -31,7 +31,7 @@ public async Task> HandleAsync( var defaultRealm = matchingRealms.FirstOrDefault()!; realm.Permissions = defaultRealm.Permissions - .Where(permission => DefaultRealmPermissions.InitialPermissions.Contains(permission.Name)) + .Where(permission => RealmPermissions.InitialPermissions.Contains(permission.Name)) .ToList(); await collection.InsertAsync(realm, cancellation: cancellation); diff --git a/Source/HttpsRichardy.Federation.Application/Policies/PermissionNamespacePolicy.cs b/Source/HttpsRichardy.Federation.Application/Policies/PermissionNamespacePolicy.cs new file mode 100644 index 0000000..c5b617b --- /dev/null +++ b/Source/HttpsRichardy.Federation.Application/Policies/PermissionNamespacePolicy.cs @@ -0,0 +1,15 @@ +namespace HttpsRichardy.Federation.Application.Policies; + +public sealed class PermissionNamespacePolicy : IPermissionNamespacePolicy +{ + public async Task EnsurePermissionIsAllowedAsync( + Realm realm, Permission permission, CancellationToken cancellation = default) + { + var isReserved = RealmPermissions.SystemPermissions + .Contains(permission.Name); + + return isReserved + ? Result.Failure(PermissionErrors.PermissionNameIsReserved) + : Result.Success(); + } +} diff --git a/Source/HttpsRichardy.Federation.Application/Usings.cs b/Source/HttpsRichardy.Federation.Application/Usings.cs index 3647bab..154c608 100644 --- a/Source/HttpsRichardy.Federation.Application/Usings.cs +++ b/Source/HttpsRichardy.Federation.Application/Usings.cs @@ -30,7 +30,6 @@ global using HttpsRichardy.Federation.Application.Providers; global using HttpsRichardy.Federation.Application.Mappers; global using HttpsRichardy.Federation.Application.Utilities; -global using HttpsRichardy.Federation.Application.Handlers.Authorization; global using FluentValidation; global using HttpsRichardy.Dispatcher.Contracts; diff --git a/Source/HttpsRichardy.Federation.Common/Constants/DefaultRealmPermissions.cs b/Source/HttpsRichardy.Federation.Common/Constants/DefaultRealmPermissions.cs deleted file mode 100644 index ff59119..0000000 --- a/Source/HttpsRichardy.Federation.Common/Constants/DefaultRealmPermissions.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace HttpsRichardy.Federation.Common.Constants; - -public static class DefaultRealmPermissions -{ - public static readonly string[] InitialPermissions = - [ - Permissions.CreateGroup, - Permissions.DeleteGroup, - Permissions.ViewGroups, - Permissions.EditGroup, - - Permissions.DeleteUser, - Permissions.EditUser, - Permissions.ViewUsers, - - Permissions.CreatePermission, - Permissions.AssignPermissions, - Permissions.RevokePermissions, - Permissions.ViewPermissions, - Permissions.EditPermission, - Permissions.DeletePermission, - - Permissions.CreateScope, - Permissions.EditScope, - Permissions.DeleteGroup, - Permissions.ViewScopes - ]; -} diff --git a/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs b/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs new file mode 100644 index 0000000..d99beb3 --- /dev/null +++ b/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs @@ -0,0 +1,57 @@ +namespace HttpsRichardy.Federation.Common.Constants; + +public static class RealmPermissions +{ + public static readonly HashSet InitialPermissions = + [ + Permissions.CreateGroup, + Permissions.DeleteGroup, + Permissions.ViewGroups, + Permissions.EditGroup, + + Permissions.DeleteUser, + Permissions.EditUser, + Permissions.ViewUsers, + + Permissions.CreatePermission, + Permissions.AssignPermissions, + Permissions.RevokePermissions, + Permissions.ViewPermissions, + Permissions.EditPermission, + Permissions.DeletePermission, + + Permissions.CreateScope, + Permissions.EditScope, + Permissions.DeleteGroup, + Permissions.ViewScopes + ]; + + public static readonly HashSet SystemPermissions = + [ + Permissions.CreateGroup, + Permissions.DeleteGroup, + Permissions.EditGroup, + Permissions.ViewGroups, + + Permissions.DeleteUser, + Permissions.EditUser, + Permissions.ViewUsers, + + Permissions.CreatePermission, + Permissions.AssignPermissions, + Permissions.RevokePermissions, + Permissions.ViewPermissions, + Permissions.EditPermission, + Permissions.DeletePermission, + + Permissions.CreateRealm, + Permissions.DeleteRealm, + Permissions.EditRealm, + Permissions.ViewRealms, + + Permissions.CreateScope, + Permissions.EditScope, + Permissions.DeleteScope, + Permissions.ViewScopes + ]; +} diff --git a/Source/HttpsRichardy.Federation.Domain/Errors/PermissionErrors.cs b/Source/HttpsRichardy.Federation.Domain/Errors/PermissionErrors.cs index 04083dc..d43db90 100644 --- a/Source/HttpsRichardy.Federation.Domain/Errors/PermissionErrors.cs +++ b/Source/HttpsRichardy.Federation.Domain/Errors/PermissionErrors.cs @@ -7,6 +7,11 @@ public static class PermissionErrors Description: "The permission with the specified name already exists." ); + public static readonly Error PermissionNameIsReserved = new( + Code: "#ERROR-7B1E2", + Description: "The permission name is reserved by the system." + ); + public static readonly Error PermissionDoesNotExist = new( Code: "#ERROR-93697", Description: "The specified permission does not exist." diff --git a/Source/HttpsRichardy.Federation.Domain/Policies/IPermissionNamespacePolicy.cs b/Source/HttpsRichardy.Federation.Domain/Policies/IPermissionNamespacePolicy.cs new file mode 100644 index 0000000..356e1f4 --- /dev/null +++ b/Source/HttpsRichardy.Federation.Domain/Policies/IPermissionNamespacePolicy.cs @@ -0,0 +1,20 @@ +namespace HttpsRichardy.Federation.Domain.Policies; + +// defines a policy responsible for protecting the system permission namespace +// from unauthorized usage by realms + +// certain permissions are reserved by the federation system and represent +// privileged administrative capabilities (e.g. managing realms or federation resources) + +// this policy ensures that realms cannot create or manipulate permissions +// whose identifiers belong to the reserved system namespace, preventing +// privilege escalation through permission name collision + +public interface IPermissionNamespacePolicy +{ + public Task EnsurePermissionIsAllowedAsync( + Realm realm, + Permission permission, + CancellationToken cancellation = default + ); +} diff --git a/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs b/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs index ba097ba..108da15 100644 --- a/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs +++ b/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs @@ -9,7 +9,9 @@ public static void AddServices(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/HttpsRichardy.Federation.WebApi/Controllers/PermissionsController.cs b/Source/HttpsRichardy.Federation.WebApi/Controllers/PermissionsController.cs index 38ca261..9e54dec 100644 --- a/Source/HttpsRichardy.Federation.WebApi/Controllers/PermissionsController.cs +++ b/Source/HttpsRichardy.Federation.WebApi/Controllers/PermissionsController.cs @@ -35,6 +35,9 @@ public async Task CreatePermissionAsync([FromBody] PermissionCrea { IsFailure: true } when result.Error == PermissionErrors.PermissionAlreadyExists => StatusCode(StatusCodes.Status409Conflict, result.Error), + + { IsFailure: true } when result.Error == PermissionErrors.PermissionNameIsReserved => + StatusCode(StatusCodes.Status409Conflict, result.Error), }; } @@ -52,6 +55,9 @@ public async Task UpdatePermissionAsync([FromRoute] string id, [F { IsFailure: true } when result.Error == PermissionErrors.PermissionDoesNotExist => StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == PermissionErrors.PermissionNameIsReserved => + StatusCode(StatusCodes.Status409Conflict, result.Error), }; } diff --git a/Source/HttpsRichardy.Federation.WebApi/Conventions/PermissionsConventions.cs b/Source/HttpsRichardy.Federation.WebApi/Conventions/PermissionsConventions.cs index 90fe560..3649ad7 100644 --- a/Source/HttpsRichardy.Federation.WebApi/Conventions/PermissionsConventions.cs +++ b/Source/HttpsRichardy.Federation.WebApi/Conventions/PermissionsConventions.cs @@ -16,6 +16,7 @@ public static void CreatePermissionAsync(PermissionCreationScheme request, Cance [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(PermissionDetailsScheme), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void UpdatePermissionAsync(string id, PermissionUpdateScheme request, CancellationToken cancellation) { } [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] diff --git a/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index 21d02e3..25297a5 100644 --- a/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs +++ b/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs @@ -33,33 +33,12 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) } defaultRealm.SecretHash = await passwordHasher.HashPasswordAsync(realmCredentials.ClientId + defaultRealm.Name); - defaultRealm.Permissions = [ - new() { Id = Identifier.Generate(), Name = Permissions.CreateGroup, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.DeleteGroup, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.ViewGroups, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.EditGroup, RealmId = defaultRealm.Id }, - - new() { Id = Identifier.Generate(), Name = Permissions.DeleteUser, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.EditUser, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.ViewUsers, RealmId = defaultRealm.Id }, - - new() { Id = Identifier.Generate(), Name = Permissions.CreateRealm, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.DeleteRealm, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.EditRealm, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.ViewRealms, RealmId = defaultRealm.Id }, - - new() { Id = Identifier.Generate(), Name = Permissions.CreatePermission, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.AssignPermissions, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.RevokePermissions, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.ViewPermissions, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.EditPermission, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.DeletePermission, RealmId = defaultRealm.Id }, - - new() { Id = Identifier.Generate(), Name = Permissions.CreateScope, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.EditScope, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.DeleteScope, RealmId = defaultRealm.Id }, - new() { Id = Identifier.Generate(), Name = Permissions.ViewScopes, RealmId = defaultRealm.Id }, - ]; + defaultRealm.Permissions = [.. RealmPermissions.SystemPermissions.Select(permissionName => new Permission + { + Id = Identifier.Generate(), + Name = permissionName, + RealmId = defaultRealm.Id + })]; var scopes = new List { diff --git a/Tests/Integration/Endpoints/PermissionEndpointTests.cs b/Tests/Integration/Endpoints/PermissionEndpointTests.cs index 21cb0b0..e923db6 100644 --- a/Tests/Integration/Endpoints/PermissionEndpointTests.cs +++ b/Tests/Integration/Endpoints/PermissionEndpointTests.cs @@ -112,6 +112,76 @@ public async Task WhenPostPermissionsWithDuplicateName_ShouldReturnConflict() Assert.Equal(PermissionErrors.PermissionAlreadyExists, error); } + [Fact(DisplayName = "[e2e] - when POST /permissions in a non-master realm with reserved system name should return 409 #ERROR-7B1E2")] + public async Task WhenPostPermissionsWithReservedSystemNameInNonMasterRealm_ShouldReturnConflict() + { + /* arrange: authenticate in master realm */ + var masterClient = factory.HttpClient.WithRealmHeader("master"); + var masterCredentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var masterAuthenticationResponse = await masterClient.PostAsJsonAsync("api/v1/identity/authenticate", masterCredentials); + var masterAuthenticationResult = await masterAuthenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(masterAuthenticationResult); + Assert.NotEmpty(masterAuthenticationResult.AccessToken); + + masterClient.WithAuthorization(masterAuthenticationResult.AccessToken); + + /* arrange: create a new realm */ + var realmPayload = _fixture.Build() + .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .Create(); + + var realmResponse = await masterClient.PostAsJsonAsync("api/v1/realms", realmPayload); + var realm = await realmResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(realm); + Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + + /* arrange: authenticate realm via OAuth 2.0 client_credentials */ + var oauthCredentials = new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", realm.ClientId }, + { "client_secret", realm.ClientSecret } + }; + + var oauthContent = new FormUrlEncodedContent(oauthCredentials); + var connectClient = factory.HttpClient; + + var oauthResponse = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", oauthContent); + var oauthResult = await oauthResponse.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, oauthResponse.StatusCode); + + Assert.NotNull(oauthResult); + Assert.NotEmpty(oauthResult.AccessToken); + + var realmClient = factory.HttpClient.WithRealmHeader(realm.Name); + + realmClient.WithAuthorization(oauthResult.AccessToken); + + /* act: attempt to create a permission using a reserved system name */ + var payload = _fixture.Build() + .With(permission => permission.Name, Permissions.ViewRealms) + .Create(); + + var response = await realmClient.PostAsJsonAsync("api/v1/permissions", payload); + + /* assert: response should be 409 Conflict */ + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + var error = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(error); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(PermissionErrors.PermissionNameIsReserved, error); + } + [Fact(DisplayName = "[e2e] - when PUT /permissions/{id} with valid data should update permission successfully")] public async Task WhenPutPermissionsWithValidData_ShouldUpdatePermissionSuccessfully() { @@ -160,6 +230,51 @@ public async Task WhenPutPermissionsWithValidData_ShouldUpdatePermissionSuccessf Assert.Equal(updatePayload.Name, updatedPermission.Name); } + [Fact(DisplayName = "[e2e] - when PUT /permissions/{id} with reserved system name should return 409 #ERROR-7B1E2")] + public async Task WhenPutPermissionsWithReservedSystemName_ShouldReturnConflict() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a custom permission */ + var createPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .Create(); + + var createResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", createPayload); + var permission = await createResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(permission); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + /* act: attempt to rename it to a reserved system permission */ + var updatePayload = _fixture.Build() + .With(update => update.Name, Permissions.ViewRealms) + .Create(); + + var response = await httpClient.PutAsJsonAsync($"api/v1/permissions/{permission.Id}", updatePayload); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 409 Conflict */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(PermissionErrors.PermissionNameIsReserved, error); + } + [Fact(DisplayName = "[e2e] - when PUT /permissions/{id} with non-existent permission should return 404 #ERROR-93697")] public async Task WhenPutPermissionsWithNonExistentPermission_ShouldReturnNotFound() { diff --git a/Tests/Integration/Fixtures/MongoDatabaseFixture.cs b/Tests/Integration/Fixtures/MongoDatabaseFixture.cs index 9d61b9d..1d0f4dd 100644 --- a/Tests/Integration/Fixtures/MongoDatabaseFixture.cs +++ b/Tests/Integration/Fixtures/MongoDatabaseFixture.cs @@ -40,8 +40,9 @@ public async Task InitializeAsync() public async Task CleanDatabaseAsync() { - var cursor = await Database.ListCollectionNamesAsync(); - var collections = await cursor.ToListAsync(); + var collections = await Database + .ListCollectionNames() + .ToListAsync(); foreach (var collectionName in collections) { diff --git a/Tests/Integration/Fixtures/WebApplicationFixture.cs b/Tests/Integration/Fixtures/WebApplicationFixture.cs index 5cc1ec3..da5faaf 100644 --- a/Tests/Integration/Fixtures/WebApplicationFixture.cs +++ b/Tests/Integration/Fixtures/WebApplicationFixture.cs @@ -17,6 +17,7 @@ public WebApplicationFixture() public async Task InitializeAsync() { await _databaseFixture.InitializeAsync(); + await _databaseFixture.CleanDatabaseAsync(); Environment.SetEnvironmentVariable("Settings__Administration__Username", "federation.testing.user"); Environment.SetEnvironmentVariable("Settings__Administration__Password", "federation.testing.password"); @@ -122,7 +123,6 @@ public async Task DisposeAsync() HttpClient.Dispose(); await _factory.DisposeAsync(); - await _databaseFixture.CleanDatabaseAsync(); await _databaseFixture.DisposeAsync(); } } diff --git a/Tests/Usings.cs b/Tests/Usings.cs index bc3f51e..7be8b25 100644 --- a/Tests/Usings.cs +++ b/Tests/Usings.cs @@ -17,6 +17,7 @@ global using HttpsRichardy.Federation.Domain.Aggregates; global using HttpsRichardy.Federation.Domain.Filtering; global using HttpsRichardy.Federation.Domain.Collections; +global using HttpsRichardy.Federation.Common.Constants; global using HttpsRichardy.Federation.Application.Services; global using HttpsRichardy.Federation.Application.Providers;