Skip to content

Commit 84a984a

Browse files
eliykatjustindbauraudreyality
authored
[PM-19585] Use Authorize attributes for simple role authorization (#5555)
- Add Authorize<T> attribute - Add IOrganizationRequirement and example implementation - Add OrganizationRequirementHandler - Add extension methods (replacing ICurrentContext) - Move custom permissions claim definitions --- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: ✨ Audrey ✨ <ajensen@bitwarden.com>
1 parent c9a42d8 commit 84a984a

16 files changed

+590
-16
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#nullable enable
2+
3+
using Microsoft.AspNetCore.Authorization;
4+
5+
namespace Bit.Api.AdminConsole.Authorization;
6+
7+
/// <summary>
8+
/// An attribute which requires authorization using the specified requirement.
9+
/// This uses the standard ASP.NET authorization middleware.
10+
/// </summary>
11+
/// <typeparam name="T">The IAuthorizationRequirement that will be used to authorize the user.</typeparam>
12+
public class AuthorizeAttribute<T>
13+
: AuthorizeAttribute, IAuthorizationRequirementData
14+
where T : IAuthorizationRequirement, new()
15+
{
16+
public IEnumerable<IAuthorizationRequirement> GetRequirements()
17+
{
18+
var requirement = new T();
19+
return [requirement];
20+
}
21+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#nullable enable
2+
3+
using Bit.Core.AdminConsole.Enums.Provider;
4+
using Bit.Core.AdminConsole.Models.Data.Provider;
5+
using Bit.Core.AdminConsole.Repositories;
6+
7+
namespace Bit.Api.AdminConsole.Authorization;
8+
9+
public static class HttpContextExtensions
10+
{
11+
public const string NoOrgIdError =
12+
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
13+
14+
/// <summary>
15+
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
16+
/// Subsequent calls will retrieve the cached value.
17+
/// Results are stored by type and therefore must be of a unique type.
18+
/// </summary>
19+
public static async Task<T> WithFeaturesCacheAsync<T>(this HttpContext httpContext, Func<Task<T>> callback)
20+
{
21+
var cachedResult = httpContext.Features.Get<T>();
22+
if (cachedResult != null)
23+
{
24+
return cachedResult;
25+
}
26+
27+
var result = await callback();
28+
httpContext.Features.Set(result);
29+
30+
return result;
31+
}
32+
33+
/// <summary>
34+
/// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
35+
/// </summary>
36+
/// <remarks>
37+
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
38+
/// </remarks>
39+
public static async Task<bool> IsProviderUserForOrgAsync(
40+
this HttpContext httpContext,
41+
IProviderUserRepository providerUserRepository,
42+
Guid userId,
43+
Guid organizationId)
44+
{
45+
var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
46+
return organizations.Any(o => o.OrganizationId == organizationId);
47+
}
48+
49+
/// <summary>
50+
/// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
51+
/// </summary>
52+
/// <remarks>
53+
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
54+
/// </remarks>
55+
private static async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizationsAsync(
56+
this HttpContext httpContext,
57+
IProviderUserRepository providerUserRepository,
58+
Guid userId)
59+
=> await httpContext.WithFeaturesCacheAsync(() =>
60+
providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
61+
62+
63+
/// <summary>
64+
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
65+
/// </summary>
66+
/// <param name="httpContext"></param>
67+
/// <returns></returns>
68+
/// <exception cref="InvalidOperationException"></exception>
69+
public static Guid GetOrganizationId(this HttpContext httpContext)
70+
{
71+
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
72+
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
73+
{
74+
throw new InvalidOperationException(NoOrgIdError);
75+
}
76+
77+
return orgId;
78+
}
79+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#nullable enable
2+
3+
using Bit.Core.Context;
4+
using Microsoft.AspNetCore.Authorization;
5+
6+
namespace Bit.Api.AdminConsole.Authorization;
7+
8+
/// <summary>
9+
/// A requirement that implements this interface will be handled by <see cref="OrganizationRequirementHandler"/>,
10+
/// which calls AuthorizeAsync with the organization details from the route.
11+
/// This is used for simple role-based checks.
12+
/// This may only be used on endpoints with {orgId} in their path.
13+
/// </summary>
14+
public interface IOrganizationRequirement : IAuthorizationRequirement
15+
{
16+
/// <summary>
17+
/// Whether to authorize a request that has this requirement.
18+
/// </summary>
19+
/// <param name="organizationClaims">
20+
/// The CurrentContextOrganization for the user if they are a member of the organization.
21+
/// This is null if they are not a member.
22+
/// </param>
23+
/// <param name="isProviderUserForOrg">
24+
/// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
25+
/// This requires a database query, call it last.
26+
/// </param>
27+
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
28+
public Task<bool> AuthorizeAsync(
29+
CurrentContextOrganization? organizationClaims,
30+
Func<Task<bool>> isProviderUserForOrg);
31+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#nullable enable
2+
3+
using System.Security.Claims;
4+
using Bit.Core.Context;
5+
using Bit.Core.Enums;
6+
using Bit.Core.Identity;
7+
using Bit.Core.Models.Data;
8+
9+
namespace Bit.Api.AdminConsole.Authorization;
10+
11+
public static class OrganizationClaimsExtensions
12+
{
13+
/// <summary>
14+
/// Parses a user's claims and returns an object representing their claims for the specified organization.
15+
/// </summary>
16+
/// <param name="user">The user who has the claims.</param>
17+
/// <param name="organizationId">The organizationId to look for in the claims.</param>
18+
/// <returns>
19+
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for that organization, or null
20+
/// if the user does not have any claims for that organization.
21+
/// </returns>
22+
public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId)
23+
{
24+
var hasClaim = GetClaimsParser(user, organizationId);
25+
26+
var role = GetRoleFromClaims(hasClaim);
27+
if (!role.HasValue)
28+
{
29+
// Not an organization member
30+
return null;
31+
}
32+
33+
return new CurrentContextOrganization
34+
{
35+
Id = organizationId,
36+
Type = role.Value,
37+
AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess),
38+
Permissions = role == OrganizationUserType.Custom
39+
? GetPermissionsFromClaims(hasClaim)
40+
: new Permissions()
41+
};
42+
}
43+
44+
/// <summary>
45+
/// Returns a function for evaluating claims for the specified user and organizationId.
46+
/// The function returns true if the claim type exists and false otherwise.
47+
/// </summary>
48+
private static Func<string, bool> GetClaimsParser(ClaimsPrincipal user, Guid organizationId)
49+
{
50+
// Group claims by ClaimType
51+
var claimsDict = user.Claims
52+
.GroupBy(c => c.Type)
53+
.ToDictionary(
54+
c => c.Key,
55+
c => c.ToList());
56+
57+
return claimType
58+
=> claimsDict.TryGetValue(claimType, out var claims) &&
59+
claims
60+
.ParseGuids()
61+
.Any(v => v == organizationId);
62+
}
63+
64+
/// <summary>
65+
/// Parses the provided claims into proper Guids, or ignore them if they are not valid guids.
66+
/// </summary>
67+
private static IEnumerable<Guid> ParseGuids(this IEnumerable<Claim> claims)
68+
{
69+
foreach (var claim in claims)
70+
{
71+
if (Guid.TryParse(claim.Value, out var guid))
72+
{
73+
yield return guid;
74+
}
75+
}
76+
}
77+
78+
private static OrganizationUserType? GetRoleFromClaims(Func<string, bool> hasClaim)
79+
{
80+
if (hasClaim(Claims.OrganizationOwner))
81+
{
82+
return OrganizationUserType.Owner;
83+
}
84+
85+
if (hasClaim(Claims.OrganizationAdmin))
86+
{
87+
return OrganizationUserType.Admin;
88+
}
89+
90+
if (hasClaim(Claims.OrganizationCustom))
91+
{
92+
return OrganizationUserType.Custom;
93+
}
94+
95+
if (hasClaim(Claims.OrganizationUser))
96+
{
97+
return OrganizationUserType.User;
98+
}
99+
100+
return null;
101+
}
102+
103+
private static Permissions GetPermissionsFromClaims(Func<string, bool> hasClaim)
104+
=> new()
105+
{
106+
AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs),
107+
AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport),
108+
AccessReports = hasClaim(Claims.CustomPermissions.AccessReports),
109+
CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections),
110+
EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection),
111+
DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection),
112+
ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups),
113+
ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies),
114+
ManageSso = hasClaim(Claims.CustomPermissions.ManageSso),
115+
ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers),
116+
ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword),
117+
ManageScim = hasClaim(Claims.CustomPermissions.ManageScim),
118+
};
119+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#nullable enable
2+
3+
using Bit.Core.AdminConsole.Repositories;
4+
using Bit.Core.Services;
5+
using Microsoft.AspNetCore.Authorization;
6+
7+
namespace Bit.Api.AdminConsole.Authorization;
8+
9+
/// <summary>
10+
/// Handles any requirement that implements <see cref="IOrganizationRequirement"/>.
11+
/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to
12+
/// determine whether the action is authorized.
13+
/// </summary>
14+
public class OrganizationRequirementHandler(
15+
IHttpContextAccessor httpContextAccessor,
16+
IProviderUserRepository providerUserRepository,
17+
IUserService userService)
18+
: AuthorizationHandler<IOrganizationRequirement>
19+
{
20+
public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";
21+
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
22+
23+
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement)
24+
{
25+
var httpContext = httpContextAccessor.HttpContext;
26+
if (httpContext == null)
27+
{
28+
throw new InvalidOperationException(NoHttpContextError);
29+
}
30+
31+
var organizationId = httpContext.GetOrganizationId();
32+
var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId);
33+
34+
var userId = userService.GetProperUserId(httpContext.User);
35+
if (userId == null)
36+
{
37+
throw new InvalidOperationException(NoUserIdError);
38+
}
39+
40+
Task<bool> IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId);
41+
42+
var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg);
43+
44+
if (authorized)
45+
{
46+
context.Succeed(requirement);
47+
}
48+
}
49+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#nullable enable
2+
3+
using Bit.Core.Context;
4+
using Bit.Core.Enums;
5+
6+
namespace Bit.Api.AdminConsole.Authorization.Requirements;
7+
8+
public class ManageUsersRequirement : IOrganizationRequirement
9+
{
10+
public async Task<bool> AuthorizeAsync(
11+
CurrentContextOrganization? organizationClaims,
12+
Func<Task<bool>> isProviderUserForOrg)
13+
=> organizationClaims switch
14+
{
15+
{ Type: OrganizationUserType.Owner } => true,
16+
{ Type: OrganizationUserType.Admin } => true,
17+
{ Permissions.ManageUsers: true } => true,
18+
_ => await isProviderUserForOrg()
19+
};
20+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#nullable enable
2+
3+
using Bit.Core.Context;
4+
5+
namespace Bit.Api.AdminConsole.Authorization.Requirements;
6+
7+
/// <summary>
8+
/// Requires that the user is a member of the organization or a provider for the organization.
9+
/// </summary>
10+
public class MemberOrProviderRequirement : IOrganizationRequirement
11+
{
12+
public async Task<bool> AuthorizeAsync(
13+
CurrentContextOrganization? organizationClaims,
14+
Func<Task<bool>> isProviderUserForOrg)
15+
=> organizationClaims is not null || await isProviderUserForOrg();
16+
}

src/Api/Utilities/ServiceCollectionExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Bit.Api.Tools.Authorization;
1+
using Bit.Api.AdminConsole.Authorization;
2+
using Bit.Api.Tools.Authorization;
23
using Bit.Api.Vault.AuthorizationHandlers.Collections;
34
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
45
using Bit.Core.IdentityServer;
@@ -105,5 +106,7 @@ public static void AddAuthorizationHandlers(this IServiceCollection services)
105106
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
106107
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
107108
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
109+
110+
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
108111
}
109112
}

src/Core/AdminConsole/Models/Data/Permissions.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json.Serialization;
2+
using Bit.Core.Identity;
23

34
namespace Bit.Core.Models.Data;
45

@@ -20,17 +21,17 @@ public class Permissions
2021
[JsonIgnore]
2122
public List<(bool Permission, string ClaimName)> ClaimsMap => new()
2223
{
23-
(AccessEventLogs, "accesseventlogs"),
24-
(AccessImportExport, "accessimportexport"),
25-
(AccessReports, "accessreports"),
26-
(CreateNewCollections, "createnewcollections"),
27-
(EditAnyCollection, "editanycollection"),
28-
(DeleteAnyCollection, "deleteanycollection"),
29-
(ManageGroups, "managegroups"),
30-
(ManagePolicies, "managepolicies"),
31-
(ManageSso, "managesso"),
32-
(ManageUsers, "manageusers"),
33-
(ManageResetPassword, "manageresetpassword"),
34-
(ManageScim, "managescim"),
24+
(AccessEventLogs, Claims.CustomPermissions.AccessEventLogs),
25+
(AccessImportExport, Claims.CustomPermissions.AccessImportExport),
26+
(AccessReports, Claims.CustomPermissions.AccessReports),
27+
(CreateNewCollections, Claims.CustomPermissions.CreateNewCollections),
28+
(EditAnyCollection, Claims.CustomPermissions.EditAnyCollection),
29+
(DeleteAnyCollection, Claims.CustomPermissions.DeleteAnyCollection),
30+
(ManageGroups, Claims.CustomPermissions.ManageGroups),
31+
(ManagePolicies, Claims.CustomPermissions.ManagePolicies),
32+
(ManageSso, Claims.CustomPermissions.ManageSso),
33+
(ManageUsers, Claims.CustomPermissions.ManageUsers),
34+
(ManageResetPassword, Claims.CustomPermissions.ManageResetPassword),
35+
(ManageScim, Claims.CustomPermissions.ManageScim),
3536
};
3637
}

0 commit comments

Comments
 (0)