diff --git a/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs b/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
new file mode 100644
index 0000000000..1f864ece66
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
@@ -0,0 +1,21 @@
+#nullable enable
+
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Api.AdminConsole.Authorization;
+
+///
+/// An attribute which requires authorization using the specified requirement.
+/// This uses the standard ASP.NET authorization middleware.
+///
+/// The IAuthorizationRequirement that will be used to authorize the user.
+public class AuthorizeAttribute
+ : AuthorizeAttribute, IAuthorizationRequirementData
+ where T : IAuthorizationRequirement, new()
+{
+ public IEnumerable GetRequirements()
+ {
+ var requirement = new T();
+ return [requirement];
+ }
+}
diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
new file mode 100644
index 0000000000..ba00ea6c18
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
@@ -0,0 +1,79 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.Enums.Provider;
+using Bit.Core.AdminConsole.Models.Data.Provider;
+using Bit.Core.AdminConsole.Repositories;
+
+namespace Bit.Api.AdminConsole.Authorization;
+
+public static class HttpContextExtensions
+{
+ public const string NoOrgIdError =
+ "A route decorated with with '[Authorize]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
+
+ ///
+ /// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
+ /// Subsequent calls will retrieve the cached value.
+ /// Results are stored by type and therefore must be of a unique type.
+ ///
+ public static async Task WithFeaturesCacheAsync(this HttpContext httpContext, Func> callback)
+ {
+ var cachedResult = httpContext.Features.Get();
+ if (cachedResult != null)
+ {
+ return cachedResult;
+ }
+
+ var result = await callback();
+ httpContext.Features.Set(result);
+
+ return result;
+ }
+
+ ///
+ /// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
+ ///
+ ///
+ /// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
+ ///
+ public static async Task IsProviderUserForOrgAsync(
+ this HttpContext httpContext,
+ IProviderUserRepository providerUserRepository,
+ Guid userId,
+ Guid organizationId)
+ {
+ var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
+ return organizations.Any(o => o.OrganizationId == organizationId);
+ }
+
+ ///
+ /// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
+ ///
+ ///
+ /// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
+ ///
+ private static async Task> GetProviderUserOrganizationsAsync(
+ this HttpContext httpContext,
+ IProviderUserRepository providerUserRepository,
+ Guid userId)
+ => await httpContext.WithFeaturesCacheAsync(() =>
+ providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
+
+
+ ///
+ /// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
+ ///
+ ///
+ ///
+ ///
+ public static Guid GetOrganizationId(this HttpContext httpContext)
+ {
+ httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
+ if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
+ {
+ throw new InvalidOperationException(NoOrgIdError);
+ }
+
+ return orgId;
+ }
+}
diff --git a/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs b/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs
new file mode 100644
index 0000000000..007647f4c0
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs
@@ -0,0 +1,31 @@
+#nullable enable
+
+using Bit.Core.Context;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Api.AdminConsole.Authorization;
+
+///
+/// A requirement that implements this interface will be handled by ,
+/// which calls AuthorizeAsync with the organization details from the route.
+/// This is used for simple role-based checks.
+/// This may only be used on endpoints with {orgId} in their path.
+///
+public interface IOrganizationRequirement : IAuthorizationRequirement
+{
+ ///
+ /// Whether to authorize a request that has this requirement.
+ ///
+ ///
+ /// The CurrentContextOrganization for the user if they are a member of the organization.
+ /// This is null if they are not a member.
+ ///
+ ///
+ /// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
+ /// This requires a database query, call it last.
+ ///
+ /// True if the requirement has been satisfied, otherwise false.
+ public Task AuthorizeAsync(
+ CurrentContextOrganization? organizationClaims,
+ Func> isProviderUserForOrg);
+}
diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs
new file mode 100644
index 0000000000..e21d153bab
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs
@@ -0,0 +1,119 @@
+#nullable enable
+
+using System.Security.Claims;
+using Bit.Core.Context;
+using Bit.Core.Enums;
+using Bit.Core.Identity;
+using Bit.Core.Models.Data;
+
+namespace Bit.Api.AdminConsole.Authorization;
+
+public static class OrganizationClaimsExtensions
+{
+ ///
+ /// Parses a user's claims and returns an object representing their claims for the specified organization.
+ ///
+ /// The user who has the claims.
+ /// The organizationId to look for in the claims.
+ ///
+ /// A representing the user's claims for that organization, or null
+ /// if the user does not have any claims for that organization.
+ ///
+ public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId)
+ {
+ var hasClaim = GetClaimsParser(user, organizationId);
+
+ var role = GetRoleFromClaims(hasClaim);
+ if (!role.HasValue)
+ {
+ // Not an organization member
+ return null;
+ }
+
+ return new CurrentContextOrganization
+ {
+ Id = organizationId,
+ Type = role.Value,
+ AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess),
+ Permissions = role == OrganizationUserType.Custom
+ ? GetPermissionsFromClaims(hasClaim)
+ : new Permissions()
+ };
+ }
+
+ ///
+ /// Returns a function for evaluating claims for the specified user and organizationId.
+ /// The function returns true if the claim type exists and false otherwise.
+ ///
+ private static Func GetClaimsParser(ClaimsPrincipal user, Guid organizationId)
+ {
+ // Group claims by ClaimType
+ var claimsDict = user.Claims
+ .GroupBy(c => c.Type)
+ .ToDictionary(
+ c => c.Key,
+ c => c.ToList());
+
+ return claimType
+ => claimsDict.TryGetValue(claimType, out var claims) &&
+ claims
+ .ParseGuids()
+ .Any(v => v == organizationId);
+ }
+
+ ///
+ /// Parses the provided claims into proper Guids, or ignore them if they are not valid guids.
+ ///
+ private static IEnumerable ParseGuids(this IEnumerable claims)
+ {
+ foreach (var claim in claims)
+ {
+ if (Guid.TryParse(claim.Value, out var guid))
+ {
+ yield return guid;
+ }
+ }
+ }
+
+ private static OrganizationUserType? GetRoleFromClaims(Func hasClaim)
+ {
+ if (hasClaim(Claims.OrganizationOwner))
+ {
+ return OrganizationUserType.Owner;
+ }
+
+ if (hasClaim(Claims.OrganizationAdmin))
+ {
+ return OrganizationUserType.Admin;
+ }
+
+ if (hasClaim(Claims.OrganizationCustom))
+ {
+ return OrganizationUserType.Custom;
+ }
+
+ if (hasClaim(Claims.OrganizationUser))
+ {
+ return OrganizationUserType.User;
+ }
+
+ return null;
+ }
+
+ private static Permissions GetPermissionsFromClaims(Func hasClaim)
+ => new()
+ {
+ AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs),
+ AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport),
+ AccessReports = hasClaim(Claims.CustomPermissions.AccessReports),
+ CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections),
+ EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection),
+ DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection),
+ ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups),
+ ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies),
+ ManageSso = hasClaim(Claims.CustomPermissions.ManageSso),
+ ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers),
+ ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword),
+ ManageScim = hasClaim(Claims.CustomPermissions.ManageScim),
+ };
+}
diff --git a/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs b/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs
new file mode 100644
index 0000000000..178090fadf
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs
@@ -0,0 +1,49 @@
+#nullable enable
+
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Services;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Bit.Api.AdminConsole.Authorization;
+
+///
+/// Handles any requirement that implements .
+/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to
+/// determine whether the action is authorized.
+///
+public class OrganizationRequirementHandler(
+ IHttpContextAccessor httpContextAccessor,
+ IProviderUserRepository providerUserRepository,
+ IUserService userService)
+ : AuthorizationHandler
+{
+ public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";
+ public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement)
+ {
+ var httpContext = httpContextAccessor.HttpContext;
+ if (httpContext == null)
+ {
+ throw new InvalidOperationException(NoHttpContextError);
+ }
+
+ var organizationId = httpContext.GetOrganizationId();
+ var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId);
+
+ var userId = userService.GetProperUserId(httpContext.User);
+ if (userId == null)
+ {
+ throw new InvalidOperationException(NoUserIdError);
+ }
+
+ Task IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId);
+
+ var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg);
+
+ if (authorized)
+ {
+ context.Succeed(requirement);
+ }
+ }
+}
diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs
new file mode 100644
index 0000000000..84f38e36c2
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+using Bit.Core.Context;
+using Bit.Core.Enums;
+
+namespace Bit.Api.AdminConsole.Authorization.Requirements;
+
+public class ManageUsersRequirement : IOrganizationRequirement
+{
+ public async Task AuthorizeAsync(
+ CurrentContextOrganization? organizationClaims,
+ Func> isProviderUserForOrg)
+ => organizationClaims switch
+ {
+ { Type: OrganizationUserType.Owner } => true,
+ { Type: OrganizationUserType.Admin } => true,
+ { Permissions.ManageUsers: true } => true,
+ _ => await isProviderUserForOrg()
+ };
+}
diff --git a/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs
new file mode 100644
index 0000000000..030509adef
--- /dev/null
+++ b/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs
@@ -0,0 +1,16 @@
+#nullable enable
+
+using Bit.Core.Context;
+
+namespace Bit.Api.AdminConsole.Authorization.Requirements;
+
+///
+/// Requires that the user is a member of the organization or a provider for the organization.
+///
+public class MemberOrProviderRequirement : IOrganizationRequirement
+{
+ public async Task AuthorizeAsync(
+ CurrentContextOrganization? organizationClaims,
+ Func> isProviderUserForOrg)
+ => organizationClaims is not null || await isProviderUserForOrg();
+}
diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs
index feeac03e54..4c8589657e 100644
--- a/src/Api/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Api/Utilities/ServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
-using Bit.Api.Tools.Authorization;
+using Bit.Api.AdminConsole.Authorization;
+using Bit.Api.Tools.Authorization;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer;
@@ -105,5 +106,7 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+
+ services.AddScoped();
}
}
diff --git a/src/Core/AdminConsole/Models/Data/Permissions.cs b/src/Core/AdminConsole/Models/Data/Permissions.cs
index 9edc3f1d50..def468f18d 100644
--- a/src/Core/AdminConsole/Models/Data/Permissions.cs
+++ b/src/Core/AdminConsole/Models/Data/Permissions.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
+using Bit.Core.Identity;
namespace Bit.Core.Models.Data;
@@ -20,17 +21,17 @@ public class Permissions
[JsonIgnore]
public List<(bool Permission, string ClaimName)> ClaimsMap => new()
{
- (AccessEventLogs, "accesseventlogs"),
- (AccessImportExport, "accessimportexport"),
- (AccessReports, "accessreports"),
- (CreateNewCollections, "createnewcollections"),
- (EditAnyCollection, "editanycollection"),
- (DeleteAnyCollection, "deleteanycollection"),
- (ManageGroups, "managegroups"),
- (ManagePolicies, "managepolicies"),
- (ManageSso, "managesso"),
- (ManageUsers, "manageusers"),
- (ManageResetPassword, "manageresetpassword"),
- (ManageScim, "managescim"),
+ (AccessEventLogs, Claims.CustomPermissions.AccessEventLogs),
+ (AccessImportExport, Claims.CustomPermissions.AccessImportExport),
+ (AccessReports, Claims.CustomPermissions.AccessReports),
+ (CreateNewCollections, Claims.CustomPermissions.CreateNewCollections),
+ (EditAnyCollection, Claims.CustomPermissions.EditAnyCollection),
+ (DeleteAnyCollection, Claims.CustomPermissions.DeleteAnyCollection),
+ (ManageGroups, Claims.CustomPermissions.ManageGroups),
+ (ManagePolicies, Claims.CustomPermissions.ManagePolicies),
+ (ManageSso, Claims.CustomPermissions.ManageSso),
+ (ManageUsers, Claims.CustomPermissions.ManageUsers),
+ (ManageResetPassword, Claims.CustomPermissions.ManageResetPassword),
+ (ManageScim, Claims.CustomPermissions.ManageScim),
};
}
diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs
index 65d5eb210a..fad7b37b5f 100644
--- a/src/Core/Identity/Claims.cs
+++ b/src/Core/Identity/Claims.cs
@@ -22,4 +22,21 @@ public static class Claims
// General
public const string Type = "type";
+
+ // Organization custom permissions
+ public static class CustomPermissions
+ {
+ public const string AccessEventLogs = "accesseventlogs";
+ public const string AccessImportExport = "accessimportexport";
+ public const string AccessReports = "accessreports";
+ public const string CreateNewCollections = "createnewcollections";
+ public const string EditAnyCollection = "editanycollection";
+ public const string DeleteAnyCollection = "deleteanycollection";
+ public const string ManageGroups = "managegroups";
+ public const string ManagePolicies = "managepolicies";
+ public const string ManageSso = "managesso";
+ public const string ManageUsers = "manageusers";
+ public const string ManageResetPassword = "manageresetpassword";
+ public const string ManageScim = "managescim";
+ }
}
diff --git a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs
new file mode 100644
index 0000000000..1901742777
--- /dev/null
+++ b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs
@@ -0,0 +1,28 @@
+using Bit.Api.AdminConsole.Authorization;
+using Microsoft.AspNetCore.Http;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Api.Test.AdminConsole.Authorization;
+
+public class HttpContextExtensionsTests
+{
+ [Fact]
+ public async Task WithFeaturesCacheAsync_OnlyExecutesCallbackOnce()
+ {
+ var httpContext = new DefaultHttpContext();
+ var callback = Substitute.For>>();
+ callback().Returns(Task.FromResult("hello world"));
+
+ // Call once
+ var result1 = await httpContext.WithFeaturesCacheAsync(callback);
+ Assert.Equal("hello world", result1);
+ await callback.ReceivedWithAnyArgs(1).Invoke();
+
+ // Call again - callback not executed again
+ var result2 = await httpContext.WithFeaturesCacheAsync(callback);
+ Assert.Equal("hello world", result2);
+ await callback.ReceivedWithAnyArgs(1).Invoke();
+ }
+
+}
diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs
new file mode 100644
index 0000000000..7e2e51a6e1
--- /dev/null
+++ b/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs
@@ -0,0 +1,60 @@
+using System.Security.Claims;
+using Bit.Api.AdminConsole.Authorization;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Test.AdminConsole.Helpers;
+using Bit.Core.Utilities;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Bit.Test.Common.Helpers;
+using Xunit;
+
+namespace Bit.Api.Test.AdminConsole.Authorization;
+
+public class OrganizationClaimsExtensionsTests
+{
+ [Theory, BitMemberAutoData(nameof(GetTestOrganizations))]
+ public void GetCurrentContextOrganization_ParsesOrganizationFromClaims(CurrentContextOrganization expected, User user)
+ {
+ var claims = CoreHelpers.BuildIdentityClaims(user, [expected], [], false)
+ .Select(c => new Claim(c.Key, c.Value));
+
+ var claimsPrincipal = new ClaimsPrincipal();
+ claimsPrincipal.AddIdentities([new ClaimsIdentity(claims)]);
+
+ var actual = claimsPrincipal.GetCurrentContextOrganization(expected.Id);
+
+ AssertHelper.AssertPropertyEqual(expected, actual);
+ }
+
+ public static IEnumerable