This demo use AAD App Roles & also provides fine-grained access control to data using Resource-based and Policy-based authorization.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
In this example, we want to provide fine-grained access control using Azure Active Directory App Roles & Groups. This way, we don't have to maintain the list of users and their roles in our application. Instead, we can keep our application focused on "role-based access".
In this example, there are 2 branches of the company & a corporate office. The following rules should apply:
- Each person should be to read their own salary data
- No person should be able to modify their own salary data
- The regional manager of a branch should be able to read & write all salary data for their branch only
- The CFO should be able to read & write all salary data for all employees (except themselves, of course)
To use this example, you will need to configure 2 Azure Active Directory App Registrations/Service Princpals & AAD groups. One for the backend API and one for the front-end web app.
-
Navigate to the Azure Active Directory blade in the Azure portal
-
Click on the App Registrations blade
-
Click New registration
-
Assign a Name, Accounts in this organizational directory only & the Redirect URI
Note: You can assign this later if you don't know the redirect URI. If running locally, the default is https://localhost:5001/signin-oidc
-
On the Overview blade, copy the following information to Notepad so you can use it later:
- Application (client) ID
- Directory (tenant) ID
-
On the Authentication blade, select the Implicit grant and hybrid flows->ID tokens check box to enable ID tokens to be retrieved using OAuth2 hybrid flow
-
On the Certificates & secrets blade, click on New client secret and create a new secret. Store this secret in Notepad so you can use it later.
-
On the Token configuration blade, add 2 new claims:
groups
- Click Add groups claim
- Select Groups assigned to the application
- Under ID, select Group ID
- Click Save
upn
- Click Add optional claim
- Select Token type->ID
- Check the box for upn
- Click Add
-
On the API permissions blade, make sure you have the following API permissions (click on Add a permission if not)
Microsoft Graph
- profile
- User.Read
-
On the Expose an API page, define 3 scopes for your API to expose. This allows you to provide different levels of access to different front-end applications that might want different levels of data (for instance, a sales app that shows sales data & a HR app that shows salary data)
- Click Add a scope
- Scope name : Salary.ReadWrite
- Who can consent : Admins only
- Fill out the other data as needed
- Click Save
Repeat these steps and create a Sale.ReadWrite scope & a Default.ReadWrite scope. You should require admin consent for the Sale.ReadWrite scope, but you can leave the Who can consent flag set to Admins and users for the Default.ReadWrite scope.
-
On the App roles blade, create 4 roles (click on Create app role).
Display Name Description Allowed member types Value General.ReadWrite General users can read & write their own data Users/Groups General.ReadWrite Salesperson.ReadWrite Salespeople can read & write their own data Users/Groups Salesperson.ReadWrite RegionalManager.ReadWrite Regional Managers can read & write their own branch data Users/Groups RegionalManager.ReadWrite CFO.ReadWrite CFO can read and write all data Users/Groups CFO.ReadWrite -
On the Manifest blade, make sure the groupMembershipClaims is set to ApplicationGroup
-
On the Groups blade, create groups for each branch & role. Assign users as needed.
Name Group Type Membership Type WebAppName_General Security Assigned WebAppName_Scranton_Salesperson Security Assigned WebAppName_Scranton_RegionalManager Security Assigned WebAppName_Stamford_Salesperson Security Assigned WebAppName_Stamford_RegionalManager Security Assigned WebAppName_Corporate_CFO Security Assigned
-
Under the Enterprise applciations blade, search for your new AAD service principal (the same name as the app registration)
-
Under the Users and groups blade, click on Add user/group and map the new AAD groups to their respective roles.
Display Name Object Type Role assigned WebAppName_General Group General.ReadWrite WebAppName_Scranton_Salesperson Group Salesperson.ReadWrite WebAppName_Scranton_RegionalManager Group RegionalManager.ReadWrite WebAppName_Stamford_Salesperson Group Salesperson.ReadWrite WebAppName_Stamford_RegionalManager Group RegionalManager.ReadWrite WebAppName_Corporate_CFO Group CFO.ReadWrite
The steps for configuring the App Registration & Service Principal for the web app are similar to the ones for the web API. You will need to add API permissions to your Web API & consent for them.
-
Under the API permissions blade, add permissions to your web API.
- Click Add a permission
- Select My APIs
- Select the app registration API
- Click Delegated permissions
- Select Default.ReadWrite & Salary.ReadWrite
- Click Add permissions
- Click Grant admin consent for
Becuase this is sensitive data (salaries, sales data, etc), we don't want to allow users to consent for themselves to expose this data to any app that might request them. Instead, we require admin consent so that we can control which apps get access to this data.
-
In the
src/DunderMifflinInfinity.API/appsettings.json
file, update the AzureAD section with the AAD app registration values you copied to Notepad before. -
In the
src/DunderMifflinInfinity.API/Data/DbInitializer.cs
file, update the values to match your app role names, group IDs and users.
-
In the
src/DunderMifflinInfinity.WebApp/appsettings.json
file, update the AzureAD section with the AAD app registration values you copied to Notepad before.Don't forget the API scopes you exposed in the web API app registration (your GUID will be different).
"DunderMifflinInfinity.Api": { "ApiBaseAddress": "https://localhost:5001", "DefaultScope": "api://09e2d303-1ad0-43f9-8d34-dcd8dc5fb4ea/Default.ReadWrite", "SalaryScope": "api://09e2d303-1ad0-43f9-8d34-dcd8dc5fb4ea/Salary.ReadWrite", "SaleScope": "api://09e2d303-1ad0-43f9-8d34-dcd8dc5fb4ea/Sale.ReadWrite" }
-
Initialize the local Sqlite database. Make sure you are in the
src/DunderMifflinInfinity.API
directory.dotnet ef database update
-
Run the API
dotnet watch run
-
Run the application (in a separate shell)
dotnet watch run
As you sign in with different users, you will see that they have different permissions.
Salesperson
As someone with the General.ReadWrite role, Dwight can see his own salary, but not his fellow employees. He also cannot create a new salary record or modify his salary.
Regional manager - Scranton
As someone with the RegionalManager.ReadWrite role, Michael can see his own salary and the salaries of his branch employees (but not those of the Stamford branch). He can modify his own employees salaries, but not his own.
Regional manager - Stamford
As someone with the RegionalManager.ReadWrite role, Josh can see his own salary and the salaries of his branch employees (but not those of the Scranton branch). He can modify his own employees salaries, but not his own.
CFO
As someone with the CFO.ReadWrite role, David can see his own salary and the salaries of all his employees. He can modify his employees salaries, but not his own.
We don't want to have to constantly issue Graph API queries to find out information about the user, so we can request that every JWT ID token that is presented to the application include the AAD groups they are a part of (that are related to the application), the app roles the user is assigned (by virtue of being in those AAD groups) and the upn of the user (so we can key off it in the database).
In the src/DunderMifflinInfinity.API/Startup.cs
file in the ConfigureServices method, we need to set up the authorization policies we want enforced.
- You can group several roles together into a single policy so that you don't have to specify them multiple times.
- You can define a list of requirements that must all return Succeeded for the policy to pass.
Add a new SalaryAuthorizationHandler
to the list of services.
In the src/DunderMifflinInfinity.API/AuthorizationHandlers/SalaryAuthorizationHandler.cs
file, the SalaryAuthorizationHandler service is responsible for evaluating the list of requirements defined in the Salary policy.
It will loop through each requirement as defined in the Startup.cs
file. For each requirement, a function will get called to evaluate it.
For the CannotModifyOwnSalaryRequirement, we need to check the User.Identity.Name
to see if it is the same UserPrincipalName
of the Salary object the user is trying to modify. No one should be able to modify their own salary.
For the OnlyManagementCanModifySalariesRequirement, we need to check the roles the signed-in user has to see if they are in a management role.
For the BranchManagerCanOnlyModifyOwnBranchSalariesRequirement, we need to check if the user is a regional manager, and if so, ensure they are only modifying data for their own branch employees.
Note: In this case, the same policies are applied to both the web API & the web app. This means you could abstract out the
AuthorizationHandlers
into a separate library that both projects depend on. However, there are likely to be differences in a real-world application, so they are copied in this case so you have the ability to customize as needed.
Get
In the src/DunderMifflinInfinity.API/Controllers/SalariesController.cs
file, we use two attributes. For the entire controller, we use the RequiredScope to ensure only users have the Salary.ReadWrite scope in their token before continuing. Then in the GetSalaries method, we use the Policies.General policy because everyone can see some salary data, but it will change depending on their role. We use Entity Framework to only pull the appropriate data for each role.
Put
In the src/DunderMifflinInfinity.API/Controllers/SalariesController.cs
file, in the PutSalary method, we use the _authorizationService to evaluate if the signed-in user is allowed to modify the Salary object. If so, we make the database change, otherwise, we forbid it. This will call the SalaryAuthorizationService and loop through all requirements.
The Web App controllers delegate accessing the web API to some helper Services (src/DunderMifflinInfinity.WebApp/Services
) that get an access token when needed.
private async Task PrepareAuthenticatedClient()
{
var accessToken = await tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
private async Task<HttpResponseMessage> ExecuteApi(HttpMethod httpMethod, string apiEndpoint, StringContent? jsonContent = null)
{
await PrepareAuthenticatedClient();
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(httpMethod, apiBaseAddress + apiEndpoint){
Content = jsonContent
};
return await httpClient.SendAsync(httpRequestMessage);
}
When the application runs and tries to access the backend API for Salary data, an access token is procured for the Salary.ReadWrite scope.
The access token contains all the information we need to decide if the user should be allowed to modify the data. Notice that we have the aud (audience, the backend API app ID), the groups the user is a part of (that are related to this app), the app roles the user is a part of, the Salary.ReadWrite scope & the upn (user principal name) uniquely identifying the user.
These services are registered in the src/DunderMifflinInfinity.WebApp/Startup.cs
file.
services.AddHttpClient<IBranchApiService, BranchApiService>();
services.AddHttpClient<IEmployeeApiService, EmployeeApiService>();
services.AddHttpClient<ISalaryApiService, SalaryApiService>();
services.AddHttpClient<ISaleApiService, SaleApiService>();
In the src/DunderMifflinInfinity.WebApp/Views/Salaries/Index.cshtml
you can see that we hide the Create new button if the user is not a manager.
We also hide the Edit and Delete buttons if the user is not a manager & is not trying to modify their own salary.
Unit tests can be found in the src/DunderMifflinInfinity.API.Tests
directory. Run with the following command in that directory.
dotnet test
Run the following command in an existing resource group.
cd inf/bicep
az deployment group create --resource-group rg-webAppWithAppRolesAndFineGrained-ussc-demo --template-file ./main.bicep --parameters ./main.parameters.json
- https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-add-app-roles-in-azure-ad-apps
- https://docs.microsoft.com/en-us/aspnet/core/security/authorization/resourcebased?view=aspnetcore-5.0
- https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-5.0
- https://docs.microsoft.com/en-us/ef/core/cli/dotnet#dotnet-ef-database-update
- https://docs.microsoft.com/en-us/ef/core/querying/related-data/explicit
- https://docs.microsoft.com/en-us/ef/core/change-tracking/explicit-tracking