├── .github
└── FUNDING.yml
├── .gitignore
├── HeimGuard.sln
├── LICENSE.txt
├── README.md
└── src
└── HeimGuard
├── AutoPolicy
├── HeimGuardAuthorizationPolicyProvider.cs
└── PermissionHandler.cs
├── HeimGuard.csproj
├── HeimGuardBuilder.cs
├── HeimGuardClient.cs
├── HeimGuardServiceRegistration.cs
└── IUserPolicyHandler.cs
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [pdevito3]
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.build/
2 | /global.json
3 | QueryBaseline.cs
4 |
5 | ## Ignore Visual Studio temporary files, build results, and
6 | ## files generated by popular Visual Studio add-ons.
7 | ##
8 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
9 |
10 | # User-specific files
11 | *.suo
12 | *.user
13 | *.userosscache
14 | *.sln.docstates
15 | *.user.sln*
16 | /test.ps1
17 | *.stackdump
18 |
19 | # User-specific files (MonoDevelop/Xamarin Studio)
20 | *.userprefs
21 |
22 | # Build results
23 | [Dd]ebug/
24 | [Dd]ebugPublic/
25 | [Rr]elease/
26 | [Rr]eleases/
27 | x64/
28 | x86/
29 | bld/
30 | [Bb]in/
31 | [Oo]bj/
32 | [Ll]og/
33 |
34 | # Visual Studio 2015 cache/options directory
35 | .vs/
36 | # Uncomment if you have tasks that create the project's static files in wwwroot
37 | #wwwroot/
38 |
39 | # BenchmarkDotNet Results
40 | [Bb]enchmarkDotNet.Artifacts/
41 |
42 | # MSTest test Results
43 | [Tt]est[Rr]esult*/
44 | [Bb]uild[Ll]og.*
45 |
46 | # NUNIT
47 | *.VisualState.xml
48 | TestResult.xml
49 |
50 | # Build Results of an ATL Project
51 | [Dd]ebugPS/
52 | [Rr]eleasePS/
53 | dlldata.c
54 |
55 | # .NET Core
56 | project.lock.json
57 | project.fragment.lock.json
58 | artifacts/
59 | #**/Properties/launchSettings.json
60 |
61 | *_i.c
62 | *_p.c
63 | *_i.h
64 | *.ilk
65 | *.meta
66 | *.obj
67 | *.pch
68 | *.pdb
69 | *.pgc
70 | *.pgd
71 | *.rsp
72 | *.sbr
73 | *.tlb
74 | *.tli
75 | *.tlh
76 | *.tmp
77 | *.tmp_proj
78 | *.log
79 | *.vspscc
80 | *.vssscc
81 | .builds
82 | *.pidb
83 | *.svclog
84 | *.scc
85 |
86 | # Chutzpah Test files
87 | _Chutzpah*
88 |
89 | # Visual C++ cache files
90 | ipch/
91 | *.aps
92 | *.ncb
93 | *.opendb
94 | *.opensdf
95 | *.sdf
96 | *.cachefile
97 | *.VC.db
98 | *.VC.VC.opendb
99 |
100 | # Visual Studio profiler
101 | *.psess
102 | *.vsp
103 | *.vspx
104 | *.sap
105 |
106 | # TFS 2012 Local Workspace
107 | $tf/
108 |
109 | # Guidance Automation Toolkit
110 | *.gpState
111 |
112 | # ReSharper is a .NET coding add-in
113 | _ReSharper*/
114 | *.[Rr]e[Ss]harper
115 | *.DotSettings.user
116 |
117 | # JustCode is a .NET coding add-in
118 | .JustCode
119 |
120 | # TeamCity is a build add-in
121 | _TeamCity*
122 |
123 | # DotCover is a Code Coverage Tool
124 | *.dotCover
125 |
126 | # Visual Studio code coverage results
127 | *.coverage
128 | *.coveragexml
129 |
130 | # NCrunch
131 | _NCrunch_*
132 | .*crunch*.local.xml
133 | nCrunchTemp_*
134 |
135 | # MightyMoose
136 | *.mm.*
137 | AutoTest.Net/
138 |
139 | # Web workbench (sass)
140 | .sass-cache/
141 |
142 | # Installshield output folder
143 | [Ee]xpress/
144 |
145 | # DocProject is a documentation generator add-in
146 | DocProject/buildhelp/
147 | DocProject/Help/*.HxT
148 | DocProject/Help/*.HxC
149 | DocProject/Help/*.hhc
150 | DocProject/Help/*.hhk
151 | DocProject/Help/*.hhp
152 | DocProject/Help/Html2
153 | DocProject/Help/html
154 |
155 | # Click-Once directory
156 | publish/
157 |
158 | # Publish Web Output
159 | *.[Pp]ublish.xml
160 | *.azurePubxml
161 | # TODO: Comment the next line if you want to checkin your web deploy settings
162 | # but database connection strings (with potential passwords) will be unencrypted
163 | *.pubxml
164 | *.publishproj
165 |
166 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
167 | # checkin your Azure Web App publish settings, but sensitive information contained
168 | # in these scripts will be unencrypted
169 | PublishScripts/
170 |
171 | # NuGet Packages
172 | *.nupkg
173 | # The packages folder can be ignored because of Package Restore
174 | **/packages/*
175 | # except build/, which is used as an MSBuild target.
176 | !**/packages/build/
177 | # Uncomment if necessary however generally it will be regenerated when needed
178 | #!**/packages/repositories.config
179 | # NuGet v3's project.json files produces more ignorable files
180 | *.nuget.props
181 | *.nuget.targets
182 |
183 | # Microsoft Azure Build Output
184 | csx/
185 | *.build.csdef
186 |
187 | # Microsoft Azure Emulator
188 | ecf/
189 | rcf/
190 |
191 | # Windows Store app package directories and files
192 | AppPackages/
193 | BundleArtifacts/
194 | Package.StoreAssociation.xml
195 | _pkginfo.txt
196 |
197 | # Visual Studio cache files
198 | # files ending in .cache can be ignored
199 | *.[Cc]ache
200 | # but keep track of directories ending in .cache
201 | !*.[Cc]ache/
202 |
203 | # Others
204 | ClientBin/
205 | ~$*
206 | *~
207 | *.dbmdl
208 | *.dbproj.schemaview
209 | *.jfm
210 | *.pfx
211 | *.publishsettings
212 | orleans.codegen.cs
213 |
214 | # Since there are multiple workflows, uncomment next line to ignore bower_components
215 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
216 | #bower_components/
217 |
218 | # RIA/Silverlight projects
219 | Generated_Code/
220 |
221 | # Backup & report files from converting an old project file
222 | # to a newer Visual Studio version. Backup files are not needed,
223 | # because we have git ;-)
224 | _UpgradeReport_Files/
225 | Backup*/
226 | UpgradeLog*.XML
227 | UpgradeLog*.htm
228 |
229 | # SQL Server files
230 | *.mdf
231 | *.ldf
232 |
233 | # Business Intelligence projects
234 | *.rdl.data
235 | *.bim.layout
236 | *.bim_*.settings
237 |
238 | # Microsoft Fakes
239 | FakesAssemblies/
240 |
241 | # GhostDoc plugin setting file
242 | *.GhostDoc.xml
243 |
244 | # Node.js Tools for Visual Studio
245 | .ntvs_analysis.dat
246 | node_modules/
247 |
248 | # Typescript v1 declaration files
249 | typings/
250 |
251 | # Visual Studio 6 build log
252 | *.plg
253 |
254 | # Visual Studio 6 workspace options file
255 | *.opt
256 |
257 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
258 | *.vbw
259 |
260 | # Visual Studio LightSwitch build output
261 | **/*.HTMLClient/GeneratedArtifacts
262 | **/*.DesktopClient/GeneratedArtifacts
263 | **/*.DesktopClient/ModelManifest.xml
264 | **/*.Server/GeneratedArtifacts
265 | **/*.Server/ModelManifest.xml
266 | _Pvt_Extensions
267 |
268 | # Paket dependency manage
269 | .paket/paket.exe
270 | paket-files/
271 |
272 | # FAKE - F# Make
273 | .fake/
274 |
275 | # JetBrains Rider
276 | .idea/
277 | *.sln.iml
278 |
279 | # CodeRush
280 | .cr/
281 |
282 | # Python Tools for Visual Studio (PTVS)
283 | __pycache__/
284 | *.pyc
285 |
286 | # Cake - Uncomment if you are using it
287 | # tools/**
288 | # !tools/packages.config
289 |
290 | # Telerik's JustMock configuration file
291 | *.jmconfig
292 |
293 | # BizTalk build output
294 | *.btp.cs
295 | *.btm.cs
296 | *.odx.cs
297 | *.xsd.cs
298 |
299 | #DS Store
300 | .DS_Store
301 |
302 | #ENV
303 | .env
304 |
305 |
--------------------------------------------------------------------------------
/HeimGuard.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{89045AE9-2215-4B51-96D9-0EF49A0CF6B7}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HeimGuard", "src\HeimGuard\HeimGuard.csproj", "{57C6135E-802F-42BF-AED5-DD3B56BC9DE2}"
6 | EndProject
7 | Global
8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
9 | Debug|Any CPU = Debug|Any CPU
10 | Release|Any CPU = Release|Any CPU
11 | EndGlobalSection
12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
13 | {57C6135E-802F-42BF-AED5-DD3B56BC9DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
14 | {57C6135E-802F-42BF-AED5-DD3B56BC9DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
15 | {57C6135E-802F-42BF-AED5-DD3B56BC9DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
16 | {57C6135E-802F-42BF-AED5-DD3B56BC9DE2}.Release|Any CPU.Build.0 = Release|Any CPU
17 | EndGlobalSection
18 | GlobalSection(NestedProjects) = preSolution
19 | {57C6135E-802F-42BF-AED5-DD3B56BC9DE2} = {89045AE9-2215-4B51-96D9-0EF49A0CF6B7}
20 | EndGlobalSection
21 | EndGlobal
22 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Paul DeVito
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ## What is HeimGuard?
7 |
8 | HeimGuard is a simple library, inspired by [PolicyServer](https://policyserver.io/) and [this talk](https://www.youtube.com/watch?v=Dlrf85NTuAU) by Dominick Baier, that allows you to easily manage permissions in your .NET projects.
9 |
10 |
11 | ## Quickstart
12 |
13 | Thankfully for us, .NET makes it very easy to protect a controller using a specific policy using the `Authorize` attribute, so let's start there:
14 |
15 | ```csharp
16 | using Microsoft.AspNetCore.Authorization;
17 | using Microsoft.AspNetCore.Mvc;
18 |
19 | [ApiController]
20 | [Route("recipes")]
21 | [Authorize(Policy = "RecipesFullAccess")]
22 | public class RecipesController : ControllerBase
23 | {
24 | [HttpGet]
25 | public IActionResult Get()
26 | {
27 | return Ok();
28 | }
29 | }
30 | ```
31 |
32 | Next, I'm going to put my user's role in my `ClaimPrincipal`. This isn't required for HeimGuard to work, but is what we'll use for this example.
33 |
34 | ```json
35 | {
36 | "sub": "145hfy662",
37 | "name": "John Smith",
38 | "aud": ["api1", "api2"],
39 | "role": ["Chef"]
40 | }
41 | ```
42 |
43 | Now I'm going to implement an interface from HeimGuard called `IUserPolicyHandler`. This handler is responsible for implementing your permissions lookup for your user. It should return an `IEnumerable` that stores all of the permissions that your user has available to them.
44 |
45 | **HeimGuard doesn't care how you store permissions and how you access them.** For simplicity sake in the example below, I'm just grabbing a static list, but this could just as easily come from a database or some external administration boundary and could be in whatever shape you want.
46 |
47 | ```csharp
48 | using System.Security.Claims;
49 | using HeimGuard;
50 | using Services;
51 |
52 | public class Permission
53 | {
54 | public string Name { get; set; }
55 | public List Roles { get; set; }
56 | }
57 |
58 | public static class DummyPermissionStore
59 | {
60 | public static List GetPermissions()
61 | {
62 | return new List()
63 | {
64 | new()
65 | {
66 | Name = "RecipesFullAccess",
67 | Roles = new List() { "Chef" }
68 | }
69 | };
70 | }
71 | }
72 |
73 | public class SimpleUserPolicyHandler : IUserPolicyHandler
74 | {
75 | private readonly IHttpContextAccessor _httpContextAccessor;
76 |
77 | public UserPolicyHandler(IHttpContextAccessor httpContextAccessor)
78 | {
79 | _httpContextAccessor = httpContextAccessor;
80 | }
81 |
82 | public async Task> GetUserPermissions()
83 | {
84 | var user = _httpContextAccessor.HttpContext?.User;
85 | if (user == null) throw new ArgumentNullException(nameof(user));
86 |
87 | // this gets the user's role(s) from their ClaimsPrincipal
88 | var roles = user.Claims
89 | .Where(c => c.Type == ClaimTypes.Role)
90 | .Select(r => r.Value)
91 | .ToArray();
92 |
93 | // this gets their permissions based on their roles. in this example, it's just using a static list
94 | var permissions = DummyPermissionStore.GetPermissions()
95 | .Where(p => p.Roles.Any(r => roles.Contains(r)))
96 | .Select(p => p.Name)
97 | .ToArray();
98 |
99 | return await Task.FromResult(permissions.Distinct());
100 | }
101 | }
102 | ```
103 |
104 | Now, all we have to do is register our `SimpleUserPolicyHandler` with `AddHeimGuard` and we're good to go:
105 |
106 | ```c#
107 | public void ConfigureServices(IServiceCollection services)
108 | {
109 | //... other services
110 | services.AddHeimGuard()
111 | .AutomaticallyCheckPermissions()
112 | .MapAuthorizationPolicies();
113 | }
114 | ```
115 |
116 | You'll notice two other methods extending `AddHeimGuard`. Nether are required, but they do make your life easier. For more details, check out [HeimGuard Enhancements](#heimguard-enhancements).
117 |
118 | ## Introduction
119 |
120 | Let's start by differentiating 3 different levels of permissions:
121 |
122 | - **Application access**: these are generally configured in your auth server and passed along in your token (e.g. using audience (`aud`) claim to determine what apis a token can be used in).
123 | - **Feature access**: permission specific check in a particular application boundary (e.g. can a user perform some action).
124 | - **Application logic**: custom business logic specific to your application (e.g. given this certain set of criteria, can a user perform some action).
125 |
126 | The goal with HeimGuard is to easily manage user’s permissions around the feature access scope of permissions in your .NET apps using the built in .NET policies you’re familiar with.
127 |
128 | Out of the box with .NET, we can easily decorate our controllers like this `[Authorize(Policy = "RecipesFullAccess")]` and register it in `AddAuthorization`, but there's a gap here, **how do we check if the user has that claim?**
129 |
130 | One of the most common solutions to this is to load up your policies in your security token.
131 |
132 | Identity is the input to your permissions that, together, determine a user's permissions.
133 |
134 | ```json
135 | {
136 | "sub": "145hfy662",
137 | "name": "John Smith",
138 | "aud": ["api1", "api2"],
139 | "permission": [
140 | "ManageRecipe",
141 | "CreateNewRecipe",
142 | "UpdateIngredients"
143 | ]
144 | }
145 | ```
146 |
147 | This can work, but but there are some downsides here:
148 |
149 | - Your JWT gets quickly overloaded, potentially to the point of being too big to even put into a cookie. Ideally, your token is only passing along user identity information only.
150 | - You don't have boundary permission context. Let's look at a couple examples:
151 | - As mentioned above, we generally use the `aud` claim (or maybe some custom one) to determine what apis your security token can be used in. So in the example above we have ` "aud": ["api1", "api2"],` and one of my permissions is `ManageRecipe`. What if I am allowed to manage recipes in `api1` but not `api2`? You could prefix them with something like `api1.ManageRecipe`, but that adds coupling, domain logic, and becomes a huge multipler in the amount of claims being passed around.
152 | - Say I have a permission `CanDrinkAlcohol` but depending on where I’m at in the world it may or may not be true based on my age. I could tag it with something like `US.CanDrink`, `UK.CanDrink`, etc. but this would be far from ideal for a variety of reasons.
153 | - Tokens are only given at authentication time, so if you need to update permissions, you need to invalidate all the issued tokens every time you make an update. You could also make token lifetimes very short to get more up to date info more often, but that is not ideal either and still has coupling of identity and permissions.
154 |
155 | So, what do we do? Well we can still get identity state from our identity server like we usually do. Usually, that should include some kind of role or set of roles that the user has been assigned to. These roles can then be mapped to permissions and used as a reference to a group of permissions.
156 |
157 | > It’s important to note that these roles should be identity based and make sense across your whole domain, not just a particular boundary. For instance, something like `InventoryManager` would be better than something like `Approver`.
158 |
159 | So we have our user and their identity roles from our auth token, but how do we know what permissions go with our roles? Well, this can be done in a variety of ways to whatever suits your needs best for your api.
160 |
161 | If you have a simple API or an API that rarely has modified permissions, maybe you just want keep a static list of role to permissions mappings in a class in your project or in your appsettings. More commonly, you'll probably want to persist them in a database somewhere. This could be in your boundary/application database or it could be in a separate administration boundary. Maybe you have both and use eventual consistency to keep them in sync. You could even add a caching layer on top of this as well and reference that.
162 |
163 | At the end of the day, you can store your permission to role mappings anywhere you want, but you still need a way to easily access them and integrate them into your permissions pipeline. This is where HeimGuard comes in.
164 |
165 | ## Getting Started
166 |
167 | ### Prerequisites
168 |
169 | Before you get HeimGuard set up, make sure that your authorization policies are set up properly. There are two important items here:
170 |
171 | 1. Add an authorization attribute (e.g. `[Authorize(Policy = "RecipesFullAccess")]`) to your controller so HeimGuard knows what policy to check against.
172 |
173 | 2. Reigster your policy
174 |
175 | ```C#
176 | services.AddAuthorization(options =>
177 | {
178 | options.AddPolicy("RecipesFullAccess",
179 | policy => policy.RequireClaim("permission", "RecipesFullAccess"));
180 | });
181 | ```
182 |
183 | > 🎉 Note that #2 isn't required if you are using [MapAuthorizationPolicies](#mapauthorizationpolicies).
184 |
185 | So for this example, let's say we have a controller like so:
186 |
187 | ```c#
188 | using HeimGuard;
189 | using Microsoft.AspNetCore.Authorization;
190 | using Microsoft.AspNetCore.Mvc;
191 |
192 | [ApiController]
193 | [Route("recipes")]
194 | [Authorize(Policy = "RecipesFullAccess")]
195 | public class RecipesController : ControllerBase
196 | {
197 | [HttpGet]
198 | public IActionResult Get()
199 | {
200 | return Ok()
201 | }
202 | }
203 | ```
204 |
205 |
206 |
207 | ### Setting Up a Permissions Store
208 |
209 | To start out, you're going to set up whatever store you want to use for your roles. This could take pretty much whatever structure you want, the only requirement here is that **a permission must be able to be narrowed down to a string that can be used in our authorization attribute.**
210 |
211 | Let's look at a couple different examples of how we might store our permissions.
212 |
213 | > 🔮 The examples below are mapping permissions to roles, but this isn't a requirement. You could just as easily associate permissions to users or even apply permissions to users as well as roles.
214 |
215 | #### Simple Static Class Store
216 |
217 | As shown in the quickstart, maybe we have a really simple policy that we just want to store in our project. We could just make a `Permission` class that has some roles associated to it and a store to access it.
218 |
219 | You could also make static strings that get used here and throughout your app to prevent spelling issues. Again, lots of flexibility here.
220 |
221 | ```c#
222 | public class Permission
223 | {
224 | public string Name { get; set; }
225 | public List Roles { get; set; }
226 | }
227 |
228 | public static class SimplePermissionStore
229 | {
230 | public static List GetPermissions()
231 | {
232 | return new List()
233 | {
234 | new()
235 | {
236 | Name = "RecipesFullAccess",
237 | Roles = new List() { "Chef" }
238 | }
239 | };
240 | }
241 | }
242 | ```
243 |
244 | #### Database Store
245 |
246 | We could also have some entities that we are storing in our application database or maybe in a separate administration boundary. Notice here how our permissions have a Guid as their key, but we can still get a string out of it using `Name` for our authorization attribute.
247 |
248 | ```c#
249 | using System.ComponentModel.DataAnnotations;
250 |
251 | public class Role
252 | {
253 | [Key]
254 | public Guid Id { get; set; }
255 | public string Name { get; set; }
256 | }
257 |
258 | public class Permission
259 | {
260 | [Key]
261 | public Guid Id { get; set; }
262 | public string Name { get; set; }
263 | }
264 |
265 | public class RolePermission
266 | {
267 | [Key]
268 | public Guid Id { get; set; }
269 |
270 | [JsonIgnore]
271 | [IgnoreDataMember]
272 | [ForeignKey("Role")]
273 | public Guid RoleId { get; set; }
274 | public Role Role { get; set; }
275 |
276 | [JsonIgnore]
277 | [IgnoreDataMember]
278 | [ForeignKey("Permission")]
279 | public Guid PermissionId { get; set; }
280 | public Permission Permission { get; set; }
281 | }
282 | ```
283 |
284 |
285 |
286 | ### Implementing a Policy Handler
287 |
288 | Now that we have a store set up, we need to determine how we get our final list of permissions for a given user.
289 |
290 | To do this, we are going to create a class that inherits from HeimGaurd's `IUserPolicyHandler` and implements a method called `GetUserPermissions`. This method will do whatever logic you need to perform to get the permissions for you user. It could do any of, but not limited to the following:
291 |
292 | 1. Check a static file or database for permissions assigned to a user
293 | 2. Get a user's roles and then reach
294 | 3. ping a database and
295 |
296 | Again, the goal here is to get a list of permissions for my user, particularly as an `IEnumerable`.
297 |
298 | #### Simple Static Class IUserPolicyHandler Example
299 |
300 | For our [Simple Static Class Store](#simple-static-class-store) example above, we have 3 main steps:
301 |
302 | 1. Get out user from our `ClaimsPrincipal` using `IHttpContextAccessor`. You could inject a `CurrentUserService` or whatever else here to accomplish this.
303 | 2. Get the given roles for that user from their token. Again, these roles could instead be stored in a database or static file as well. You could not even use roles at all and map permissions directly to a user.
304 | 3. Get the permissions assigned to that role from our static list.
305 |
306 | ```c#
307 |
308 | public class SimpleUserPolicyHandler : IUserPolicyHandler
309 | {
310 | private readonly IHttpContextAccessor _httpContextAccessor;
311 |
312 | public UserPolicyHandler(IHttpContextAccessor httpContextAccessor)
313 | {
314 | _httpContextAccessor = httpContextAccessor;
315 | }
316 |
317 | public async Task> GetUserPermissions()
318 | {
319 | var user = _httpContextAccessor.HttpContext?.User;
320 | if (user == null) throw new ArgumentNullException(nameof(user));
321 |
322 | // this gets the user's role(s) from their ClaimsPrincipal
323 | var roles = user
324 | .Claims.Where(c => c.Type == ClaimTypes.Role)
325 | .Select(r => r.Value)
326 | .ToArray();
327 |
328 | // this gets their permissions based on their roles. in this example, it's just using a static list
329 | var permissions = SimplePermissionStore.GetPermissions()
330 | .Where(p => p.Roles.Any(r => roles.Contains(r)))
331 | .Select(p => p.Name)
332 | .ToArray();
333 |
334 | return await Task.FromResult(permissions.Distinct());
335 | }
336 | }
337 | ```
338 |
339 | #### Database Static Class IUserPolicyHandler Example
340 |
341 | For our [Database Store](#database-store) example above, we have the same 3 steps, just implemented slightly differently to accomodate our database. As a matter of fact, the only difference here is the `var permissions` assignment and injecting my `DbContext`. This is only because I have a similar pattern for both stores, yours could look very different depending on your schema. You could also use a repo, built in method, etc to perform this action and make it more testable.
342 |
343 | **However it is implemented, the only thing that matters here is returning a list of strings as your final permissions.**
344 |
345 | ```c#
346 | public class DatabaseUserPolicyHandler : IUserPolicyHandler
347 | {
348 | private readonly RecipesDbContext _dbContext;
349 | private readonly IHttpContextAccessor _httpContextAccessor;
350 |
351 | public UserPolicyHandler(RecipesDbContext dbContext, IHttpContextAccessor httpContextAccessor)
352 | {
353 | _dbContext = dbContext;
354 | _httpContextAccessor = httpContextAccessor;
355 | }
356 |
357 | public async Task> GetUserPermissions()
358 | {
359 | var user = _httpContextAccessor.HttpContext?.User;
360 | if (user == null) throw new ArgumentNullException(nameof(user));
361 |
362 | var roles = user.Claims
363 | .Where(c => c.Type == ClaimTypes.Role)
364 | .Select(r => r.Value)
365 | .ToArray();
366 |
367 | var permissions = await _dbContext.RolePermissions
368 | .Where(rp => roles.Contains(rp.Role.Name))
369 | .Select(rp => rp.Permission.Name)
370 | .ToArrayAsync();
371 |
372 | return await Task.FromResult(permissions.Distinct());
373 | }
374 | }
375 | ```
376 |
377 | #### Enhancing Your `HasPermission` Checks
378 |
379 | The method `GetUserPermissions()` method requires all permissions for a user to be returned. When using a database, you might want to enhance the permformance of this call. You can do this by having your db use an `Exists` operation. To do this, you can implement the `HasPermission` method on your `UserPolicyHandler`. For example:
380 |
381 | ```csharp
382 |
383 | public class DatabaseUserPolicyHandler : IUserPolicyHandler
384 | {
385 | private readonly RecipesDbContext _dbContext;
386 | private readonly IHttpContextAccessor _httpContextAccessor;
387 |
388 | public UserPolicyHandler(RecipesDbContext dbContext, IHttpContextAccessor httpContextAccessor)
389 | {
390 | _dbContext = dbContext;
391 | _httpContextAccessor = httpContextAccessor;
392 | }
393 |
394 | public async Task> GetUserPermissions()
395 | {
396 | // ...
397 | }
398 |
399 | public async Task HasPermission(string permission)
400 | {
401 | var roles = await GetRoles();
402 |
403 | // super admins can do everything
404 | if (roles.Contains(Role.SuperAdmin().Value))
405 | return true;
406 |
407 | return await _dbContext.RolePermissions
408 | .Where(rp => roles.Contains(rp.Role.Name))
409 | .Select(rp => rp.Permission.Name)
410 | .AnyAsync(x => x == permission);
411 | }
412 | }
413 | ```
414 |
415 |
416 |
417 | ### Registering HeimGuard
418 |
419 | Once you have your `IUserPolicyHandler` implementation set up, just go to your service builder and register HeimGuard like so:
420 |
421 | ```c#
422 | public void ConfigureServices(IServiceCollection services)
423 | {
424 | //...
425 | services.AddHeimGuard()
426 | .AutomaticallyCheckPermissions()
427 | .MapAuthorizationPolicies();
428 | // OR...
429 | services.AddHeimGuard()
430 | .AutomaticallyCheckPermissions()
431 | .MapAuthorizationPolicies();
432 | }
433 | ```
434 |
435 | And that's it! I've added a couple extension methods on here as they are recommended by default, but they are not required. For more details, check out [HeimGuard Enhancements](#heimguard-enhancements).
436 |
437 | ## HeimGuard Enhancements
438 |
439 | There are currently two extensions on HeimGuard that are both optional, but depending on your workflow, may save you a lot of manual work.
440 |
441 | ### AutomaticallyCheckPermissions
442 |
443 | - `AutomaticallyCheckPermissions` will automatically checks user permissions when an authorization attribute is used. Again, this is optional, but without this, we would need
444 | - to add something like this to our controller or service/handler that it calls:
445 |
446 | ```csharp
447 | using HeimGuard;
448 | using Microsoft.AspNetCore.Authorization;
449 | using Microsoft.AspNetCore.Mvc;
450 |
451 | [ApiController]
452 | [Route("recipes")]
453 | [Authorize]
454 | public class RecipesController : ControllerBase
455 | {
456 | private readonly IHeimGuardClient _heimGuard;
457 |
458 | public RecipesController(IHeimGuardClient heimGuard)
459 | {
460 | _heimGuard = heimGuard;
461 | }
462 |
463 | [HttpGet]
464 | public IActionResult Get()
465 | {
466 | return _heimGuard.HasPermissionAsync("RecipesFullAccess")
467 | ? Ok()
468 | : Forbidden();
469 |
470 | // OR...
471 |
472 | await _heimGuard.MustHavePermission(Permissions.CanAddRecipe);
473 | return Ok();
474 | }
475 | }
476 | ```
477 |
478 | ### MapAuthorizationPolicies
479 |
480 | - `MapAuthorizationPolicies` will automatically map authorization attributes to ASP.NET Core authorization policies that haven't already been mapped. That means you don't have to do something like this for all your policies:
481 |
482 | ```csharp
483 | services.AddAuthorization(options =>
484 | {
485 | options.AddPolicy("RecipesFullAccess",
486 | policy => policy.RequireClaim("permission", "RecipesFullAccess"));
487 | });
488 | ```
489 |
490 | > 🧳 Note that if you manually register anything in here it will take presidence over the dynamically added policy.
491 |
492 | ## Custom Policies
493 |
494 | Custom policies can still be written and used as they normally would be in .NET. **Be careful here in that these can get to the grey area of business logic vs authorization.**
495 |
496 | * [Microsoft's docs for one requirement](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-6.0#use-a-handler-for-one-requirement)
497 | * [Microsoft's docs for multiple requirements](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-6.0#use-a-handler-for-multiple-requirements)
498 |
499 | Generally:
500 |
501 | 1. Write a custom requirement that extends Microsoft's `IAuthorizationRequirement`
502 | 2. Write a handler for that requirement so that any invoked policy that has the custom requirement in it will leverage it.
503 | * You can use HeimGuard DI in these handlers to easily check if the given user has the permission at all and then perform your custom requirement checks.
504 | 3. Register that handler in startup
505 | 4. Set up your controller
506 |
507 | ### ❗️ Important Note
508 |
509 | It's important to note that custom policies can not be automatically resolved with `AutomaticallyCheckPermissions`. That doesn't mean that you have to remove `AutomaticallyCheckPermissions` if you use any custom policies, but you'll need to be deliberate with how you set up your controllers. Sepcifically, you can still add the `Authorize` attribute, but you won't pass it a policy like you normally would. Instead, you'll build the custom requirement and involk your custom handler, which could (and likely should) leverage HeimGuard with DI.
510 |
511 | ## Tenants
512 |
513 | When working in a multitenant app, you might end up having different roles across different tenants. For example, say I am an `Admin` in Organization 1, but a `User` in Organization 2. The `Admin` role will likely add a lot of permissions that the user role wouldnt have, but how do we check what organization the user is in for that particular request?
514 |
515 | If your token is configured to have only your current tenant context (e.g. when I logged in my token only got populated with my roles for `Organization 1`, even though I have access ti other organiations), you can grab that claim from your token and use it in your `IUserPolicyHandler` implementation.
516 |
517 | Many times this won't be the case though. If you don't know what your tenant context until later in the process, it will generally be easiest to check permissions without using the `Authorize` attribute at all and strictly checking using this method as a stand alone option. Otherwise, you don't have the context to know what tenant you are working with.
518 |
519 | For example, you could add a method to you `IUserPolicyHandler` (or a new service) that can take in a user and get their permissions based on their tenant (i.e. organization).
520 |
521 | ```c#
522 | public bool GetUserPermissionsByTenant(Guid tenantId)
523 | {
524 | var userId = _currentUserService.GetUserId();
525 | if (userId == null) throw new ArgumentNullException(nameof(userId));
526 |
527 | var roles = _dbContext.UserTenantRoles
528 | .Where(utr => utr.TenantId == tenantId && utr.UserId == userId)
529 | .Select(utr => utr.Role.Name)
530 | .ToArray();
531 |
532 | var permissions = await _dbContext.RolePermissions
533 | .Where(rp => roles.Contains(rp.Role.Name))
534 | .Select(rp => rp.Permission.Name)
535 | .ToArrayAsync();
536 |
537 | return await Task.FromResult(permissions.Distinct());
538 | }
539 | ```
540 |
541 | Then you could call this inside of your controller or in your CQRS handler.
542 |
543 | > 🧢 It's worth noting that at the end of the day, this approach isn't leveraging anything in HeimGuard, so if you need something like this throughout your whole app, then it's probably not even worth bothering with HeimGuard.
544 |
545 | ## Caching
546 |
547 | A potentially downside to this approach of permission mapping is that it can get chatty. If this is causing performance issues for you, one option might be to use a redis cache in your `IUserPolicyHandler` implementation.
548 |
549 | ## Multiple Policies Per Attribute
550 |
551 | What if you want to assign multiple policies to a single authorization attribute? At that point, your going to want to build a [custom policy assertion](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies) using a function.
552 |
553 | ```cs
554 | options.AddPolicy("ThisThingOrThatThing", policy =>
555 | policy.RequireAssertion(context =>
556 | context.User.HasClaim(c =>
557 | (c.Type == "ThisThing" ||
558 | c.Type == "ThatThing"))));
559 | ```
560 |
561 | Alternatively, you can use the just have the `Authorize` attribute on a controller and manually check for permissions in your controller, handler, service, etc. like so.
562 | ```csharp
563 | public async Task Handle(AddRecipeCommand request, CancellationToken cancellationToken)
564 | {
565 | if(!await _heimGuard.HasPermissionAsync(Permissions.ThisThing) && !await _heimGuard.HasPermissionAsync(Permissions.ThisThing))
566 | throw new ForbiddenAccessException();
567 |
568 | var recipe = Recipe.Create(request.RecipeToAdd);
569 | await _recipeRepository.Add(recipe, cancellationToken);
570 |
571 | await _unitOfWork.CommitChanges(cancellationToken);
572 |
573 | var recipeAdded = await _recipeRepository.GetById(recipe.Id, cancellationToken: cancellationToken);
574 | return _mapper.Map(recipeAdded);
575 | }
576 |
577 | ```
578 |
579 |
580 | ## Example
581 | Check out [this example project](https://github.com/pdevito3/HeimGuardExamplePermissions) for one of many options for setting up HeimGuard
582 |
--------------------------------------------------------------------------------
/src/HeimGuard/AutoPolicy/HeimGuardAuthorizationPolicyProvider.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard.AutoPolicy
2 | {
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Authorization;
5 | using Microsoft.Extensions.Options;
6 |
7 | public class HeimGuardAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
8 | {
9 | ///
10 | /// Initializes a new instance of the class.
11 | ///
12 | /// The options.
13 | public HeimGuardAuthorizationPolicyProvider(IOptions options) : base(options)
14 | {
15 | }
16 |
17 | ///
18 | /// Gets a from the given
19 | ///
20 | /// The policy name to retrieve.
21 | ///
22 | /// The named .
23 | ///
24 | public override async Task GetPolicyAsync(string policyName)
25 | {
26 | // check static policies and add it if it isn't already there
27 | var policy = await base.GetPolicyAsync(policyName) ?? new AuthorizationPolicyBuilder()
28 | .AddRequirements(new PermissionRequirement(policyName))
29 | .Build();
30 |
31 | return policy;
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/HeimGuard/AutoPolicy/PermissionHandler.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard.AutoPolicy
2 | {
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Authorization;
5 |
6 | internal class PermissionRequirement : IAuthorizationRequirement
7 | {
8 | public PermissionRequirement(string name)
9 | {
10 | Name = name;
11 | }
12 | public string Name { get; private set; }
13 | }
14 |
15 | internal class PermissionHandler : AuthorizationHandler
16 | {
17 | private readonly IHeimGuardClient _guardClient;
18 |
19 | public PermissionHandler(IHeimGuardClient guardClient)
20 | {
21 | _guardClient = guardClient;
22 | }
23 |
24 | ///
25 | /// Uses HeimGuard permission check to automatically confirm that a user has the appropriate permissions given a particular context.
26 | ///
27 | protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
28 | {
29 | // no cancellation token available on AuthorizationHandler: https://github.com/aspnet/Security/issues/1598
30 | if (await _guardClient.HasPermissionAsync(requirement.Name))
31 | {
32 | context.Succeed(requirement);
33 | }
34 | }
35 | }
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/src/HeimGuard/HeimGuard.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | 1.1.0
37 | 1.1.0
38 | 1.1.0
39 | MIT
40 | https://github.com/pdevito3/heimguard
41 | git
42 | ./nupkg
43 | false
44 | Paul DeVito
45 | A small and simple library that allows you to easily manage permissions in your .NET projects.
46 | true
47 | HeimGuard
48 | HeimGuard
49 | https://github.com/pdevito3/heimguard
50 | permission permissions authorization policy policies role roles
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/HeimGuard/HeimGuardBuilder.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard
2 | {
3 | using AutoPolicy;
4 | using Microsoft.AspNetCore.Authorization;
5 | using Microsoft.Extensions.DependencyInjection;
6 |
7 | ///
8 | /// Builder for HeimGuard DI
9 | ///
10 | public class HeimGuardBuilder
11 | {
12 | public IServiceCollection Services { get; }
13 |
14 | public HeimGuardBuilder(IServiceCollection services)
15 | {
16 | Services = services;
17 | }
18 |
19 | ///
20 | /// Automatically maps authorization attributes to ASP.NET Core authorization policies that haven't already been
21 | /// added.
22 | ///
23 | public HeimGuardBuilder MapAuthorizationPolicies()
24 | {
25 | Services.AddAuthorizationCore();
26 | Services.AddTransient();
27 |
28 | return this;
29 | }
30 |
31 | ///
32 | /// Automatically checks user permissions when an authorization attribute is used.
33 | ///
34 | public HeimGuardBuilder AutomaticallyCheckPermissions()
35 | {
36 | Services.AddHttpContextAccessor();
37 | Services.AddTransient();
38 |
39 | return this;
40 | }
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/src/HeimGuard/HeimGuardClient.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard
2 | {
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Linq;
6 | using System.Security.Claims;
7 | using System.Threading.Tasks;
8 |
9 | ///
10 | /// The handler that interacts with the policy handler to expose guard methods for your user permissions.
11 | ///
12 | public interface IHeimGuardClient
13 | {
14 | ///
15 | /// Checks if the user has a particular permission.
16 | ///
17 | ///
18 | /// A task that represents the asynchronous permission check. The task result contains
19 | /// the true if the user has the given permission and false if they do not.
20 | Task HasPermissionAsync(string permission);
21 |
22 | ///
23 | /// Guards against users without the given permission. Throws an Exception of type
24 | /// if the user does not have the given permission. Does nothing if the
25 | /// user has the given permission.
26 | ///
27 | /// The name of the permission the user must have access to.
28 | /// The Exception that should be thrown if a user does not have the given permission.
29 | /// User does not have the given permission.
30 | Task MustHavePermission(string permission)
31 | where TException : Exception, new();
32 | }
33 |
34 | ///
35 | public class HeimGuardClient : IHeimGuardClient
36 | {
37 | private readonly IUserPolicyHandler _userPolicyHandler;
38 |
39 | ///
40 | /// Initializes a new instance of the class.
41 | ///
42 | public HeimGuardClient(IUserPolicyHandler userPolicyHandler)
43 | {
44 | _userPolicyHandler = userPolicyHandler;
45 | }
46 |
47 | ///
48 | public async Task HasPermissionAsync(string permission)
49 | => await _userPolicyHandler.HasPermission(permission);
50 |
51 | ///
52 | public async Task MustHavePermission(string permission)
53 | where TException : Exception, new()
54 | {
55 | if (!await HasPermissionAsync(permission))
56 | {
57 | try
58 | {
59 | throw Activator.CreateInstance(typeof(TException), permission) as TException;
60 | }
61 | catch (MissingMethodException)
62 | {
63 | throw new TException();
64 | }
65 | }
66 | }
67 | }
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/src/HeimGuard/HeimGuardServiceRegistration.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard
2 | {
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | public static class HeimGuardServiceRegistration
6 | {
7 | ///
8 | /// Adds HeimGuard service to apply permissions based on the current user's policy.
9 | ///
10 | /// The services.
11 | ///
12 | public static HeimGuardBuilder AddHeimGuard(this IServiceCollection services)
13 | where TUserPolicyHandler : class, IUserPolicyHandler
14 | {
15 | services.AddScoped();
16 | services.AddTransient();
17 |
18 | return new HeimGuardBuilder(services);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/HeimGuard/IUserPolicyHandler.cs:
--------------------------------------------------------------------------------
1 | namespace HeimGuard
2 | {
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 |
8 | ///
9 | /// Provides an abstraction for handling the role:permission mappings for a given user in am IEnumerable of strings.
10 | ///
11 | public interface IUserPolicyHandler
12 | {
13 | ///
14 | /// Returns an IEnumerable of strings that represents a distinct list of a given user's permissions.
15 | ///
16 | /// that represents the asynchronous operation, containing the list of permissions for the current user.
17 | Task> GetUserPermissions();
18 |
19 | ///
20 | /// Returns a boolean value indicating whether the current user has the specified permission.
21 | ///
22 | /// that represents the asynchronous operation, containing a boolean indicating whether the current user has the specified permission.
23 | async Task HasPermission(string permission) => (await GetUserPermissions()).Contains(permission);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------