From 97a87861aaba9cd9fe57b0035bbdde81ce3ed38f Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 10 Apr 2026 14:33:28 +0100 Subject: [PATCH 1/5] AuthZen Example using Enforcer --- .../.idea/.gitignore | 15 ++ .../.idea/copilot.data.migration.agent.xml | 6 + .../.idea/copilot.data.migration.ask.xml | 6 + .../copilot.data.migration.ask2agent.xml | 6 + .../.idea/copilot.data.migration.edit.xml | 6 + .../.idea/encodings.xml | 4 + .../.idea/indexLayout.xml | 8 + .../.idea.PolicyDrivenExpenses/.idea/vcs.xml | 7 + .../AuthZenPolicyServer.csproj | 24 +++ .../AuthZenPolicyServer/Pages/Home.cshtml | 27 ++++ .../AuthZenPolicyServer/Pages/Home.cshtml.cs | 59 +++++++ .../Pages/_ViewImports.cshtml | 2 + .../Policies/expenses.alfa | 102 ++++++++++++ .../AuthZenPolicyServer/Policies/global.alfa | 9 ++ .../AuthZenPolicyServer/Program.cs | 39 +++++ .../Properties/launchSettings.json | 23 +++ .../SubjectAttributeProvider.cs | 33 ++++ .../appsettings.Development.json | 8 + .../AuthZenPolicyServer/appsettings.json | 9 ++ .../PolicyDrivenExpenses.sln | 22 +++ .../PolicyDrivenExpenses.sln.DotSettings.user | 16 ++ .../WebApp/ApplicationDbContext.cs | 11 ++ .../AllowAllAuthorizeExpenseClaimActions.cs | 34 ++++ .../AuthZenAuthorizeExpenseClaimActions.cs | 108 +++++++++++++ .../WebApp/Authorization/AuthorizeResult.cs | 14 ++ .../DenyAllAuthorizeExpenseClaimActions.cs | 35 +++++ .../IAuthorizeExpenseClaimActions.cs | 13 ++ .../WebApp/Domain/IExpenseClaimService.cs | 22 +++ .../WebApp/Domain/IExpenseClaimSubmission.cs | 21 +++ .../Domain/IGenerateAccessDeniedContent.cs | 33 ++++ .../WebApp/Domain/IManagerLookupService.cs | 6 + .../Domain/InMemoryExpenseClaimService.cs | 147 +++++++++++++++++ .../Domain/InMemoryManagerLookupService.cs | 40 +++++ .../WebApp/Pages/AccessDenied.cshtml | 24 +++ .../WebApp/Pages/AccessDenied.cshtml.cs | 17 ++ .../WebApp/Pages/Account/Login.cshtml | 47 ++++++ .../WebApp/Pages/Account/Login.cshtml.cs | 59 +++++++ .../WebApp/Pages/Account/Logout.cshtml | 19 +++ .../WebApp/Pages/Account/Logout.cshtml.cs | 16 ++ .../WebApp/Pages/Expenses/Approve.cshtml | 72 +++++++++ .../WebApp/Pages/Expenses/Approve.cshtml.cs | 122 +++++++++++++++ .../WebApp/Pages/Expenses/Create.cshtml | 108 +++++++++++++ .../WebApp/Pages/Expenses/Create.cshtml.cs | 148 ++++++++++++++++++ .../WebApp/Pages/Home.cshtml | 45 ++++++ .../WebApp/Pages/Home.cshtml.cs | 10 ++ .../WebApp/Pages/Shared/_Layout.cshtml | 84 ++++++++++ .../Shared/_ValidationScriptsPartial.cshtml | 4 + .../WebApp/Pages/_Layout.cshtml | 2 + .../WebApp/Pages/_ViewImports.cshtml | 4 + .../WebApp/Pages/_ViewStart.cshtml | 3 + .../PolicyDrivenExpenses/WebApp/Program.cs | 102 ++++++++++++ .../WebApp/Properties/launchSettings.json | 23 +++ .../PolicyDrivenExpenses/WebApp/README.md | 23 +++ .../PolicyDrivenExpenses/WebApp/WebApp.csproj | 15 ++ .../WebApp/appsettings.Development.json | 8 + .../WebApp/appsettings.json | 9 ++ 56 files changed, 1879 insertions(+) create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/_ViewImports.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/expenses.alfa create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/global.alfa create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Properties/launchSettings.json create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.Development.json create mode 100644 Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.json create mode 100644 Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln create mode 100644 Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/ApplicationDbContext.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AllowAllAuthorizeExpenseClaimActions.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthorizeResult.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/DenyAllAuthorizeExpenseClaimActions.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/IAuthorizeExpenseClaimActions.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimService.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimSubmission.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IGenerateAccessDeniedContent.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IManagerLookupService.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryExpenseClaimService.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryManagerLookupService.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_Layout.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_ValidationScriptsPartial.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_Layout.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewImports.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewStart.cshtml create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Program.cs create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/Properties/launchSettings.json create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/WebApp.csproj create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.Development.json create mode 100644 Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.json diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore new file mode 100644 index 0000000..a3db6dc --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.AuthZenExample.iml +/modules.xml +/projectSettingsUpdater.xml +/contentModel.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml new file mode 100644 index 0000000..ba43f69 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj new file mode 100644 index 0000000..92cfa97 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + ..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\10.0.3\Microsoft.AspNetCore.dll + + + diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml new file mode 100644 index 0000000..812f566 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml @@ -0,0 +1,27 @@ +@page +@model AuthZenPolicyServer.Pages.HomeModel +@{ + ViewData["Title"] = "Home"; +} + + + +
+

Welcome to the AuthZen Policy Server

+

This server is responsible for serving and managing the expense claim authorization decisions.

+ @foreach (var policy in Model.PolicyFiles) + { +

Policy: @policy.Name

+
+
+
@Html.Raw(Model.PolicyHtml(policy.Content))
+
+
+ } +
diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml.cs new file mode 100644 index 0000000..4d1b285 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/Home.cshtml.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Text.RegularExpressions; +using System.Reflection; + +namespace AuthZenPolicyServer.Pages +{ + public class HomeModel : PageModel + { + public string PolicyText { get; set; } = string.Empty; + public List<(string Name, string Content)> PolicyFiles { get; set; } = new(); + + public void OnGet() + { + var assembly = Assembly.GetExecutingAssembly(); + var resources = assembly.GetManifestResourceNames() + .Where(r => r.Contains(".Policies.") && r.EndsWith(".alfa")); + foreach (var resource in resources) + { + using var stream = assembly.GetManifestResourceStream(resource); + using var reader = new StreamReader(stream!); + var content = reader.ReadToEnd(); + var name = resource.Substring(resource.LastIndexOf(".Policies.") + 10); + PolicyFiles.Add((name, content)); + } + } + + public static string ColorizePolicy(string policy) + { + // Colorize comments first + string result = Regex.Replace(policy, "//.*", "$0", RegexOptions.None, TimeSpan.FromSeconds(1)); + // Colorize strings + result = Regex.Replace(result, "\"([^\"]*)\"", "\"$1\"", RegexOptions.None, TimeSpan.FromSeconds(1)); + // Keywords + string[] keywords = new[] { "namespace", "import", "attribute", "policyset", "policy", "apply", "firstApplicable", "denyUnlessPermit", "permitUnlessDeny", "target", "clause", "rule", "condition", "permit", "deny", "on", "advice" , "money" , "int" , "double" , "time" , "obligation" , "string" , "date" , "let"}; + foreach (var keyword in keywords) + { + result = Regex.Replace(result, $@"\b{keyword}\b", $"{keyword}", RegexOptions.None, TimeSpan.FromSeconds(1)); + } + // Numbers + result = Regex.Replace(result, "(?<=\\s|^)([0-9]+(\\.[0-9]+)?)(?=\\s|$)", "$1", RegexOptions.None, TimeSpan.FromSeconds(1)); + // Braces + result = result.Replace("{", "{"); + result = result.Replace("}", "}"); + // HTML encode everything except our tags + result = Regex.Replace(result, "(<[^>]+>|[^<]+)", match => + { + if (match.Value.StartsWith("<")) + return match.Value; // leave tags alone + return System.Net.WebUtility.HtmlEncode(match.Value); + }); + // Fix double-encoding of quotes inside attributes + result = result.Replace("'", "'").Replace(""", "\""); + return result; + } + + public HtmlString PolicyHtml(string policy) => new HtmlString(ColorizePolicy(policy)); + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/_ViewImports.cshtml b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..e631058 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Pages/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@namespace AuthZenPolicyServer.Pages + diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/expenses.alfa b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/expenses.alfa new file mode 100644 index 0000000..8dc8ceb --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/expenses.alfa @@ -0,0 +1,102 @@ +namespace acmeCorp +{ + + import Oasis.Functions.* + import Oasis.Attributes.* + import Enforcer.AuthZen.* + + attribute ExpenseTotal { id ="total" type=money category=resourceCat} + attribute ApproverId { id ="approver" type=string category=resourceCat} + + // + // Policies for handling the creation, and approval of expense claims + // + policyset expenses + { + target clause ResourceType == "expenses" + apply denyUnlessPermit + + policy CreateExpenseClaim + policy SubmitExpenseClaim + policy ViewClaimsToApprove + policy ApproveAndRejectClaims + } + + policy CreateExpenseClaim { + target clause Action == "CreateClaim" + apply denyUnlessPermit + + rule CanCreateExpenseClaim { + condition Role == "employee" + permit + } + + on deny + { + advice authZenContext + { + error = "Must be an employee to create a claim" + } + } + } + + policy SubmitExpenseClaim + { + apply permitUnlessDeny + + target clause Action == "SubmitClaim" + rule { + condition ExpenseTotal > 1000:money and Role == "employee" + deny + on deny + { + advice authZenContext + { + error = "Claim must be less than 1000 GBP" + } + } + } + rule { + condition Role != "employee" + deny + + on deny + { + advice authZenContext + { + error = "Must be an employee to submit a claim" + } + } + } + } + + policy ViewClaimsToApprove + { + target clause Action == "ListClaimsToApprove" + apply denyUnlessPermit + + rule CanListClaims { + condition Role == "manager" + permit + } + + on deny + { + advice authZenContext + { + error = "Must be a manager to approve claims" + } + } + } + + policy ApproveAndRejectClaims + { + target clause Action == "AcceptClaim" or Action == "RejectClaim" + apply permitUnlessDeny + + rule { + condition Subject.Identifier != ApproverId and Role == "manager" + deny + } + } +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/global.alfa b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/global.alfa new file mode 100644 index 0000000..e5b6306 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Policies/global.alfa @@ -0,0 +1,9 @@ +namespace acmeCorp +{ + policyset global + { + apply firstApplicable + policy expenses + } +} + diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs new file mode 100644 index 0000000..2763256 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs @@ -0,0 +1,39 @@ +using AuthZenPolicyServer; +using Rsk.Enforcer; +using Rsk.Enforcer.AuthZen; +using Rsk.Enforcer.PAP.Store; +using Rsk.Enforcer.PEP; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorPages(); + builder.Services + .AddEnforcer("acmeCorp.global",options => + { + options.Licensee = "DEMO"; + options.LicenseKey = "eyJhdXRoIjoiREVNTyIsImV4cCI6IjIwMjYtMDQtMzBUMDA6MDA6MDAiLCJpYXQiOiIyMDI2LTAzLTMwVDExOjUwOjA1LjQxMDU5NTFaIiwib3JnIjoiREVNTyIsImF1ZCI6N30=.mtYt37KGtQFJ5je0XJGckWrOx6lqCF5QwraMPJyGFgzYOq8sAFARoIjCKpJ0JpbpCRbCcaTFFhekfHU6NLvJka/ZzfsOYM4JHBSQpol2Z38PwkR4p8J6ONBi/SYOIvXrTk48Tf09Tvo2WHeoiZ9/MLu4IN7+w8sib0fUdkt/cY1PKHHzofBBHPsOT4/LOyxZoVIFLsINC5IOCkGf1vkmCADVFTszOY5nwUf3CNBs+C6UwfpHnvggnMnZpanW45WoWDDcQHgxwS13LgH6k+0XBUrPdcFhTR9mlSuboDspctvVeNASUBWcSLLdGY7GhK2RAWEAf9bbsTrSHErqIK+gx0XcDaq+n94q/qW3swJGGjUlcj+PaGPhmoEojYfwFWWZU6y4dz45XC941GpsYZEGYSVos5+oJMdreCOZqoPXhjEiqmRDgNT7llQ4bixr9voW3N1WKrfy6Ftr2ZYPv/tSOZb3wofGkpLSpPAw/XiyWUOkIiuVajR9CM8//pWQCOZodL1/xuXlioW8EVECXoGDhreDaGhc5BIEycJC/Fv0rgrnFxrPbStm8z+jmigGhN7G7quXaZr+VHhr+WEgjqbB3MSUhR1f/jwjKtiQMEoU7EDiC9BsNkV+KmGKr+o23HlvM2mwE5/rOa/ORgJ3LZmad2yBi6CYge8lwmSLWABMEmc="; + }) + .AddPolicyEnforcementPoint(o => o.Bias = PepBias.Deny) + .AddAuthZen() + .AddAuthZenAdvice() + .AddPolicyAttributeProvider() + .AddEmbeddedPolicyStore(typeof(Program).Assembly, "AuthZenPolicyServer.Policies"); + + var app = builder.Build(); + + app.UseEnforcerAuthZen(); + app.UseStaticFiles(); + app.UseRouting(); + app.MapRazorPages(); + app.MapGet("/", context => + { + context.Response.Redirect("/Home"); + return Task.CompletedTask; + }); + app.Run(); + } +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Properties/launchSettings.json b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Properties/launchSettings.json new file mode 100644 index 0000000..5b1460b --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5208", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7064;http://localhost:5208", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs new file mode 100644 index 0000000..4404532 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs @@ -0,0 +1,33 @@ +using Rsk.Enforcer.Oasis.PolicyModel; +using Rsk.Enforcer.PIP; +using Rsk.Enforcer.PolicyModels; + +namespace AuthZenPolicyServer; + +public class AcmeCorpPerson() +{ + [PolicyAttributeValue(PolicyAttributeCategories.Subject, "role")] + public IEnumerable Roles { get; init; } = []; +} + +public class SubjectAttributeProvider : RecordAttributeValueProvider +{ + private static readonly Dictionary people = new() + { + ["bob"] = new AcmeCorpPerson() { Roles = ["employee"]}, + ["alice"] = new AcmeCorpPerson() { Roles = ["employee","manager"]}, + }; + + protected override async Task GetRecordValue(IAttributeResolver attributeResolver, CancellationToken ct) + { + IReadOnlyCollection? identifiers = await attributeResolver + .Resolve(Rsk.Enforcer.Oasis.Attributes.Subject.Identifier, ct); + + string? identifier = identifiers.SingleOrDefault(); + if (identifier == null) return null!; + + AcmeCorpPerson person = new AcmeCorpPerson(); + + return people[identifier]; + } +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.Development.json b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.json b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln b/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln new file mode 100644 index 0000000..62f1199 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{10B37E26-0292-47E2-AFF8-37C074922F77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthZenPolicyServer", "AuthZenPolicyServer\AuthZenPolicyServer.csproj", "{EF708F15-E567-4151-8BE4-A1FB5BD56EEA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {10B37E26-0292-47E2-AFF8-37C074922F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10B37E26-0292-47E2-AFF8-37C074922F77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10B37E26-0292-47E2-AFF8-37C074922F77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10B37E26-0292-47E2-AFF8-37C074922F77}.Release|Any CPU.Build.0 = Release|Any CPU + {EF708F15-E567-4151-8BE4-A1FB5BD56EEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF708F15-E567-4151-8BE4-A1FB5BD56EEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF708F15-E567-4151-8BE4-A1FB5BD56EEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF708F15-E567-4151-8BE4-A1FB5BD56EEA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user b/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user new file mode 100644 index 0000000..0f4cf32 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user @@ -0,0 +1,16 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/ApplicationDbContext.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/ApplicationDbContext.cs new file mode 100644 index 0000000..746f591 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/ApplicationDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace WebApp; + +public sealed class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ +} + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AllowAllAuthorizeExpenseClaimActions.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AllowAllAuthorizeExpenseClaimActions.cs new file mode 100644 index 0000000..b7cfbbe --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AllowAllAuthorizeExpenseClaimActions.cs @@ -0,0 +1,34 @@ +using WebApp.Domain; + +namespace WebApp.Authorization; + +public sealed class AllowAllAuthorizeExpenseClaimActions : IAuthorizeExpenseClaimActions +{ + private static readonly AuthorizeResult Success = new AuthorizeResult(true); + + public Task CanCreateClaim(string submitterUserId) + { + return Task.FromResult(Success); + } + + public Task CanSubmitClaim(string submitterUserId, decimal grossCost) + { + return Task.FromResult(Success); + } + + public Task CanApproveAndRejectClaims(string approverUserId) + { + return Task.FromResult(Success); + } + + public Task CanApproveClaims(string approverUserId, IEnumerable submission) + { + return Task.FromResult(Success); + } + + public Task CanRejectClaims(string approverUserId, IEnumerable submission) + { + return Task.FromResult(Success); + } +} + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs new file mode 100644 index 0000000..5182c1e --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using Rsk.AuthZen.Client; +using WebApp.Domain; + +namespace WebApp.Authorization; + +public class AuthZenAuthorizeExpenseClaimActions(IAuthZenClient client) : IAuthorizeExpenseClaimActions +{ + public Task CanCreateClaim(string submitterUserId) + { + AuthZenSingleRequestBuilder requestBuilder = new AuthZenSingleRequestBuilder(); + + requestBuilder + .SetSubject(submitterUserId, "user"); + + requestBuilder.SetAction("CreateClaim"); + requestBuilder.SetResource("newExpense", "expenses"); + + var request = requestBuilder.Build(); + + return Authorize(request); + } + + + public Task CanSubmitClaim(string submitterUserId, decimal grossCost) + { + AuthZenSingleRequestBuilder requestBuilder = new AuthZenSingleRequestBuilder(); + + requestBuilder + .SetSubject(submitterUserId, "user"); + + requestBuilder.SetAction("SubmitClaim"); + requestBuilder + .SetResource("newExpense", "expenses") + .Add("total",grossCost); + + var request = requestBuilder.Build(); + + return Authorize(request); + } + + public Task CanApproveAndRejectClaims(string approverUserId) + { + AuthZenSingleRequestBuilder requestBuilder = new AuthZenSingleRequestBuilder(); + + requestBuilder + .SetSubject(approverUserId, "user"); + + requestBuilder + .SetAction("ListClaimsToApprove"); + + requestBuilder + .SetResource("expenseList", "expenses") + ; + + var request = requestBuilder.Build(); + + return Authorize(request); + } + + public Task CanApproveClaims(string approverUserId, IEnumerable submissions) + { + return AuthorizeClaimSubmissionsAction(approverUserId, submissions,"AcceptClaim"); + } + + public Task CanRejectClaims(string approverUserId, IEnumerable submissions) + { + return AuthorizeClaimSubmissionsAction(approverUserId, submissions,"RejectClaim"); + } + + private async Task AuthorizeClaimSubmissionsAction(string approverUserId, + IEnumerable submissions , string action) + { + var boxCarRequestBuilder = new AuthZenBoxcarRequestBuilder(); + + foreach (IExpenseClaimSubmission submission in submissions) + { + var rb = boxCarRequestBuilder.AddRequest(); + rb.SetAction(action); + rb.SetSubject(approverUserId, "user"); + rb.SetResource(submission.SubmissionId, "expenses") + .Add("approver", submission.ApproverUserId); + } + + AuthZenBoxcarEvaluationRequest? request = boxCarRequestBuilder.Build(); + + AuthZenBoxcarResponse? result = await client.Evaluate(request); + + bool success = result.Evaluations.All(e => e.Decision == Decision.Permit); + return new AuthorizeResult(success); + } + + private async Task Authorize(AuthZenEvaluationRequest request) + { + AuthZenResponse response = await client.Evaluate(request); + bool success = response.Decision == Decision.Permit; + + if (success == false) + { + string? error = + JsonSerializer.Deserialize(response.Context).GetProperty("error").GetString(); + + return new AuthorizeResult(false, [error ?? String.Empty]); + } + + return new AuthorizeResult(success); + } +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthorizeResult.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthorizeResult.cs new file mode 100644 index 0000000..073a735 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthorizeResult.cs @@ -0,0 +1,14 @@ +namespace WebApp.Authorization; + +public struct AuthorizeResult(bool success, IEnumerable messages) +{ + public IEnumerable Messages { get; } = messages; + private static readonly IEnumerable EmptyMessages = []; + + public AuthorizeResult(bool success) : this(success,EmptyMessages) + { + + } + + public bool Success { get; } = success; +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/DenyAllAuthorizeExpenseClaimActions.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/DenyAllAuthorizeExpenseClaimActions.cs new file mode 100644 index 0000000..83fa72a --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/DenyAllAuthorizeExpenseClaimActions.cs @@ -0,0 +1,35 @@ + using WebApp.Domain; + + namespace WebApp.Authorization; + +public sealed class DenyAllAuthorizeExpenseClaimActions : IAuthorizeExpenseClaimActions +{ + private static readonly AuthorizeResult Failure = new AuthorizeResult(false, ["All requests will be denied"]); + + public Task CanCreateClaim(string submitterUserId) + { + return Task.FromResult(Failure); + } + + public Task CanSubmitClaim(string submitterUserId, decimal grossCost) + { + return Task.FromResult(Failure); + } + + public Task CanApproveAndRejectClaims(string approverUserId) + { + return Task.FromResult(Failure); + } + + public Task CanApproveClaims(string approverUserId, IEnumerable submission) + { + return Task.FromResult(Failure); + } + + public Task CanRejectClaims(string approverUserId, IEnumerable submission) + { + return Task.FromResult(Failure); + } + +} + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/IAuthorizeExpenseClaimActions.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/IAuthorizeExpenseClaimActions.cs new file mode 100644 index 0000000..81fd6a1 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/IAuthorizeExpenseClaimActions.cs @@ -0,0 +1,13 @@ +using WebApp.Domain; + +namespace WebApp.Authorization; + +public interface IAuthorizeExpenseClaimActions +{ + Task CanCreateClaim(string submitterUserId); + Task CanSubmitClaim(string submitterUserId, decimal grossCost); + + Task CanApproveAndRejectClaims(string approverUserId); + Task CanApproveClaims(string approverUserId , IEnumerable submission); + Task CanRejectClaims(string approverUserId , IEnumerable submission); +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimService.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimService.cs new file mode 100644 index 0000000..5b091e2 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimService.cs @@ -0,0 +1,22 @@ +namespace WebApp.Domain; + +public interface IExpenseClaimService +{ + Task Create( + string submitterUserId, + string description, + DateOnly claimDate, + decimal grossCost, + decimal tax, + string costCentre); + + Task GetById(string submissionId); + + Task> GetBySubmitterUserId(string submitterUserId); + + Task> GetByApproverUserId(string approverUserId); + + Task UpdateStatus( + string submissionId, + ExpenseClaimStatus status); +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimSubmission.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimSubmission.cs new file mode 100644 index 0000000..18e8d2b --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IExpenseClaimSubmission.cs @@ -0,0 +1,21 @@ +namespace WebApp.Domain; + +public enum ExpenseClaimStatus +{ + Submitted, + Rejected, + Approved +} + +public interface IExpenseClaimSubmission +{ + string SubmissionId { get; } + string SubmitterUserId { get; } + string ApproverUserId { get; } + string Description { get; } + DateOnly ClaimDate { get; } + decimal GrossCost { get; } + decimal Tax { get; } + string CostCentre { get; } + ExpenseClaimStatus Status { get; } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IGenerateAccessDeniedContent.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IGenerateAccessDeniedContent.cs new file mode 100644 index 0000000..0a3b097 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IGenerateAccessDeniedContent.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Mvc; + +namespace WebApp.Domain; + +public interface IGenerateAccessDeniedContent +{ + ActionResult Redirect(IEnumerable messages); + IEnumerable Messages(string reasonCode); +} + + + +public class InMemoryGeneratedAccessDeniedContent : IGenerateAccessDeniedContent +{ + private ConcurrentDictionary> messagesMap = new(); + + public ActionResult Redirect(IEnumerable messages) + { + string reasonCode = Guid.NewGuid().ToString(); + messagesMap.TryAdd(reasonCode, messages); + return new RedirectToPageResult("/AccessDenied", null , new + { + reason = reasonCode + }, null); + } + + public IEnumerable Messages(string reasonCode) + { + messagesMap.TryRemove(reasonCode, out IEnumerable? messages); + return messages ?? []; + } +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IManagerLookupService.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IManagerLookupService.cs new file mode 100644 index 0000000..2d95644 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/IManagerLookupService.cs @@ -0,0 +1,6 @@ +namespace WebApp.Domain; + +public interface IManagerLookupService +{ + Task GetManagerUserId(string submitterUserId); +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryExpenseClaimService.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryExpenseClaimService.cs new file mode 100644 index 0000000..ccc6f27 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryExpenseClaimService.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; + +namespace WebApp.Domain; + +public sealed class InMemoryExpenseClaimService(IManagerLookupService managerLookupService) : IExpenseClaimService +{ + private readonly IManagerLookupService managerLookupService = managerLookupService; + private static readonly ConcurrentDictionary claims = new(); + + public async Task Create( + string submitterUserId, + string description, + DateOnly claimDate, + decimal grossCost, + decimal tax, + string costCentre) + { + ValidateCreateInput(submitterUserId, description, grossCost, tax, costCentre); + + var approverUserId = await managerLookupService.GetManagerUserId(submitterUserId); + if (string.IsNullOrWhiteSpace(approverUserId)) + { + throw new InvalidOperationException($"No approver found for submitter '{submitterUserId}'."); + } + + var claim = new ExpenseClaimSubmission( + SubmissionId: Guid.NewGuid().ToString("N"), + SubmitterUserId: submitterUserId.Trim(), + ApproverUserId: approverUserId.Trim(), + Description: description.Trim(), + ClaimDate: claimDate, + GrossCost: grossCost, + Tax: tax, + CostCentre: costCentre.Trim(), + Status: ExpenseClaimStatus.Submitted); + + claims[claim.SubmissionId] = claim; + return claim; + } + + public Task GetById(string submissionId) + { + if (string.IsNullOrWhiteSpace(submissionId)) + { + return Task.FromResult(null); + } + + claims.TryGetValue(submissionId, out var claim); + return Task.FromResult(claim); + } + + public Task> GetBySubmitterUserId(string submitterUserId) + { + if (string.IsNullOrWhiteSpace(submitterUserId)) + { + return Task.FromResult>([]); + } + + var normalized = submitterUserId.Trim(); + var results = claims.Values + .Where(c => string.Equals(c.SubmitterUserId, normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(c => c.ClaimDate) + .Cast() + .ToList(); + + return Task.FromResult>(results); + } + + public Task> GetByApproverUserId(string approverUserId) + { + if (string.IsNullOrWhiteSpace(approverUserId)) + { + return Task.FromResult>([]); + } + + var normalized = approverUserId.Trim(); + var results = claims.Values + .Where(c => string.Equals(c.ApproverUserId, normalized, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(c => c.ClaimDate) + .Cast() + .ToList(); + + return Task.FromResult>(results); + } + + public Task UpdateStatus(string submissionId, ExpenseClaimStatus status) + { + if (string.IsNullOrWhiteSpace(submissionId)) + { + throw new ArgumentException("Submission id is required.", nameof(submissionId)); + } + + if (!claims.TryGetValue(submissionId, out var existing)) + { + throw new KeyNotFoundException($"No expense claim found with id '{submissionId}'."); + } + + var updated = existing with { Status = status }; + claims[submissionId] = updated; + + return Task.FromResult(updated); + } + + private static void ValidateCreateInput( + string submitterUserId, + string description, + decimal grossCost, + decimal tax, + string costCentre) + { + if (string.IsNullOrWhiteSpace(submitterUserId)) + { + throw new ArgumentException("Submitter user id is required.", nameof(submitterUserId)); + } + + if (string.IsNullOrWhiteSpace(description)) + { + throw new ArgumentException("Description is required.", nameof(description)); + } + + if (grossCost <= 0) + { + throw new ArgumentOutOfRangeException(nameof(grossCost), "Gross cost must be greater than zero."); + } + + if (tax < 0) + { + throw new ArgumentOutOfRangeException(nameof(tax), "Tax cannot be negative."); + } + + if (string.IsNullOrWhiteSpace(costCentre)) + { + throw new ArgumentException("Cost centre is required.", nameof(costCentre)); + } + } + + private sealed record ExpenseClaimSubmission( + string SubmissionId, + string SubmitterUserId, + string ApproverUserId, + string Description, + DateOnly ClaimDate, + decimal GrossCost, + decimal Tax, + string CostCentre, + ExpenseClaimStatus Status) : IExpenseClaimSubmission; +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryManagerLookupService.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryManagerLookupService.cs new file mode 100644 index 0000000..00a65bc --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Domain/InMemoryManagerLookupService.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Identity; + +namespace WebApp.Domain; + +public sealed class InMemoryManagerLookupService(UserManager userManager) : IManagerLookupService +{ + private static readonly IReadOnlyDictionary ManagerUserNameMap = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["alice"] = "bob", + ["bob"] = "alice" + }; + + public async Task GetManagerUserId(string submitterUserId) + { + if (string.IsNullOrWhiteSpace(submitterUserId)) + { + throw new ArgumentException("Submitter user id is required.", nameof(submitterUserId)); + } + + var submitter = await userManager.FindByIdAsync(submitterUserId.Trim()); + if (submitter is null || string.IsNullOrWhiteSpace(submitter.UserName)) + { + throw new InvalidOperationException($"Submitter user '{submitterUserId}' was not found."); + } + + if (!ManagerUserNameMap.TryGetValue(submitter.UserName, out var managerUserName)) + { + throw new InvalidOperationException($"No manager mapping exists for '{submitter.UserName}'."); + } + + var manager = await userManager.FindByNameAsync(managerUserName); + if (manager is null) + { + throw new InvalidOperationException($"Mapped manager '{managerUserName}' was not found."); + } + + return manager.Id; + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml new file mode 100644 index 0000000..60c8bdd --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml @@ -0,0 +1,24 @@ +@page "/AccessDenied" +@model WebApp.Pages.AccessDeniedModel +@{ + ViewData["Title"] = "Access Denied"; +} + +
+
+
+
+

Access Denied

+ @foreach (string message in @Model.Messages) + { +

@message

+ } + +
+
+
+
+ diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml.cs new file mode 100644 index 0000000..00fa250 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/AccessDenied.cshtml.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using WebApp.Domain; + +namespace WebApp.Pages; + +public class AccessDeniedModel(IGenerateAccessDeniedContent accessDeniedContent) : PageModel +{ + [BindProperty(SupportsGet = true)] + public string? Reason { get; set; } + + public IEnumerable Messages => accessDeniedContent.Messages(Reason ?? ""); + + public void OnGet() + { + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml new file mode 100644 index 0000000..9a7a3bb --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml @@ -0,0 +1,47 @@ +@page +@model WebApp.Pages.Account.LoginModel +@{ + ViewData["Title"] = "Sign in"; +} + +
+
+
+
+

Sign in

+ + @if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) + { + + } + +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ +
+
+ +

+ Try alice or bob with password Passw0rd! +

+
+
+
+
diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml.cs new file mode 100644 index 0000000..64815fa --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Login.cshtml.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace WebApp.Pages.Account; + +public class LoginModel(SignInManager signInManager) : PageModel +{ + private readonly SignInManager _signInManager = signInManager; + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public string? ReturnUrl { get; set; } + public string? ErrorMessage { get; set; } + + public void OnGet(string? returnUrl = null) + { + ReturnUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/Home" : returnUrl; + } + + public async Task OnPostAsync(string? returnUrl = null) + { + ReturnUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/Home" : returnUrl; + + if (!ModelState.IsValid) + { + return Page(); + } + + var result = await _signInManager.PasswordSignInAsync( + Input.UserName, + Input.Password, + Input.RememberMe, + lockoutOnFailure: false); + + if (result.Succeeded) + { + return LocalRedirect(ReturnUrl); + } + + ErrorMessage = "Invalid username or password."; + return Page(); + } + + public sealed class InputModel + { + [Required] + public string UserName { get; set; } = string.Empty; + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + } +} + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml new file mode 100644 index 0000000..1bf23c0 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml @@ -0,0 +1,19 @@ +@page +@model WebApp.Pages.Account.Logout + +@{ + Layout = null; +} + + + + + + + + +
+ +
+ + \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml.cs new file mode 100644 index 0000000..e2293c2 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace WebApp.Pages.Account; + +public class Logout(SignInManager signInManager) : PageModel +{ +private readonly SignInManager _signInManager = signInManager; + +public async Task OnPostAsync() +{ + await _signInManager.SignOutAsync(); + return RedirectToPage("/Home"); +} +} \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml new file mode 100644 index 0000000..19fea27 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml @@ -0,0 +1,72 @@ +@page +@model WebApp.Pages.Expenses.ApproveModel +@{ + ViewData["Title"] = "Approve Claims"; +} + +
+
+
+
+

Claims Assigned To You

+ + @if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) + { + + } + + @if (Model.Claims.Count == 0) + { +

No submitted claims are currently assigned to you.

+ } + else + { +
+
+ + + + + + + + + + + + + + + @foreach (var claim in Model.Claims) + { + + + + + + + + + + + } + +
DateSubmitterDescriptionGrossTaxCost CentreStatus
+ + @claim.ClaimDate.ToString("yyyy-MM-dd")@claim.SubmitterUserId@claim.Description@claim.GrossCost.ToString("C")@claim.Tax.ToString("C")@claim.CostCentre + @claim.Status +
+
+ +
+ + + Back +
+
+ } +
+
+
+
+ diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml.cs new file mode 100644 index 0000000..02dff86 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Approve.cshtml.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using WebApp.Authorization; +using WebApp.Domain; + +namespace WebApp.Pages.Expenses; + +[Authorize] +public class ApproveModel(IExpenseClaimService expenseClaimService, IGenerateAccessDeniedContent accessDenied, IAuthorizeExpenseClaimActions pep) : PageModel +{ + [BindProperty] + public List SelectedClaimIds { get; set; } = []; + + [BindProperty] + public string Action { get; set; } = string.Empty; + + public IReadOnlyList Claims { get; private set; } = []; + + public string? StatusMessage { get; private set; } + + public async Task OnGet() + { + var approverUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(approverUserId)) + { + return Challenge(); + } + + AuthorizeResult canApproveAndRejectClaims = await pep.CanApproveAndRejectClaims(approverUserId); + if (!canApproveAndRejectClaims.Success) + { + return accessDenied.Redirect(canApproveAndRejectClaims.Messages); + } + + await LoadExpenseClaims(approverUserId); + return Page(); + } + + public async Task OnPost() + { + var approverUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(approverUserId)) + { + return Challenge(); + } + + var action = Action?.Trim().ToLowerInvariant(); + if (action is not ("approve" or "reject")) + { + StatusMessage = "Choose an action."; + await LoadExpenseClaims(approverUserId); + return Page(); + } + + var assignedClaims = await expenseClaimService.GetByApproverUserId(approverUserId); + Dictionary allowedIds = assignedClaims + .Where(c => c.Status == ExpenseClaimStatus.Submitted) + .ToDictionary(e => e.SubmissionId,e=>e); + + List selectedIds = SelectedClaimIds + .Where(id => !string.IsNullOrWhiteSpace(id) && allowedIds.ContainsKey(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + List expensesToActUpon = assignedClaims.Where(c => selectedIds.Contains(c.SubmissionId)).ToList(); + + Func, Task> authorizationCall = + action == "approve" ? pep.CanApproveClaims : pep.CanRejectClaims; + + AuthorizeResult claimActionAuthorizationResult = await authorizationCall(approverUserId, expensesToActUpon ); + + if (!claimActionAuthorizationResult.Success) + { + return accessDenied.Redirect(claimActionAuthorizationResult.Messages); + } + + foreach (var submissionId in selectedIds) + { + var nextStatus = action == "approve" ? ExpenseClaimStatus.Approved : ExpenseClaimStatus.Rejected; + await expenseClaimService.UpdateStatus(submissionId, nextStatus); + } + + StatusMessage = selectedIds.Count == 0 + ? "No claims were selected." + : $"{(action == "approve" ? "Approved" : "Rejected")} {selectedIds.Count} claim(s)."; + + SelectedClaimIds.Clear(); + await LoadExpenseClaims(approverUserId); + return Page(); + } + + private async Task LoadExpenseClaims(string approverUserId) + { + var claims = await expenseClaimService.GetByApproverUserId(approverUserId); + + Claims = claims + .Where(c => c.Status == ExpenseClaimStatus.Submitted) + .OrderBy(c => c.ClaimDate) + .Select(c => new PendingApprovalViewModel( + c.SubmissionId, + c.SubmitterUserId, + c.Description, + c.ClaimDate, + c.GrossCost, + c.Tax, + c.CostCentre, + c.Status)) + .ToList(); + } + + public sealed record PendingApprovalViewModel( + string SubmissionId, + string SubmitterUserId, + string Description, + DateOnly ClaimDate, + decimal GrossCost, + decimal Tax, + string CostCentre, + ExpenseClaimStatus Status); +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml new file mode 100644 index 0000000..70048b7 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml @@ -0,0 +1,108 @@ +@page +@model WebApp.Pages.Expenses.CreateModel +@{ + ViewData["Title"] = "Submit Expense Claim"; +} + +
+
+
+
+

Submit Expense Claim

+

Enter expense details and review your open claims below.

+ + @if (Model.Submitted) + { + + } + +
+
+ + + +
+ +
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + + +
+ +
+ + Cancel +
+
+
+
+ +
+
+

Your Open Claims

+ + @if (Model.ExistingClaims.Count == 0) + { +

You have no submitted or rejected claims.

+ } + else + { +
+ + + + + + + + + + + + + @foreach (var claim in Model.ExistingClaims) + { + + + + + + + + + } + +
DateDescriptionGrossTaxCost CentreStatus
@claim.ClaimDate.ToString("yyyy-MM-dd")@claim.Description@claim.GrossCost.ToString("C")@claim.Tax.ToString("C")@claim.CostCentre + + @claim.Status + +
+
+ } +
+
+
+
diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml.cs new file mode 100644 index 0000000..4c53d6c --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Expenses/Create.cshtml.cs @@ -0,0 +1,148 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.Rendering; +using WebApp.Authorization; +using WebApp.Domain; + +namespace WebApp.Pages.Expenses; + +[Authorize] +public class CreateModel( + IExpenseClaimService expenseClaimService, + IAuthorizeExpenseClaimActions authorizeExpenseClaimActions, + IGenerateAccessDeniedContent accessDeniedService) : PageModel +{ + [BindProperty] + public ExpenseClaimSubmissionModel Submission { get; set; } = new(); + + public bool Submitted { get; private set; } + + public IReadOnlyList ExistingClaims { get; private set; } = []; + + public IEnumerable CostCentres => + [ + new("G&A", "G&A"), + new("R&D", "R&D"), + new("Sales", "Sales"), + new("PS", "PS"), + new("Maintenance", "Maintenance") + ]; + + public async Task OnGet() + { + Submission.ClaimDate = DateOnly.FromDateTime(DateTime.Today); + + var submitterUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(submitterUserId)) + { + return Challenge(); + } + + + AuthorizeResult canCreateAuthorizationResult = await authorizeExpenseClaimActions.CanCreateClaim(submitterUserId); + if (!canCreateAuthorizationResult.Success) + { + return accessDeniedService.Redirect(canCreateAuthorizationResult.Messages); + } + + await LoadExistingClaims(submitterUserId); + return Page(); + } + + public async Task OnPost() + { + var submitterUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(submitterUserId)) + { + return Challenge(); + } + + if (!ModelState.IsValid) + { + await LoadExistingClaims(submitterUserId); + return Page(); + } + + var canSubmit = await authorizeExpenseClaimActions.CanSubmitClaim( + submitterUserId, + Submission.GrossCost!.Value); + if (!canSubmit.Success) + { + return accessDeniedService.Redirect(canSubmit.Messages); + } + + await expenseClaimService.Create( + submitterUserId, + Submission.Description, + Submission.ClaimDate!.Value, + Submission.GrossCost!.Value, + Submission.Tax!.Value, + Submission.CostCentre); + + Submitted = true; + ModelState.Clear(); + Submission = new ExpenseClaimSubmissionModel + { + ClaimDate = DateOnly.FromDateTime(DateTime.Today) + }; + + await LoadExistingClaims(submitterUserId); + return Page(); + } + + private async Task LoadExistingClaims(string submitterUserId) + { + var claims = await expenseClaimService.GetBySubmitterUserId(submitterUserId); + ExistingClaims = claims + .Where(c => c.Status != ExpenseClaimStatus.Approved) + .OrderByDescending(c => c.ClaimDate) + .Select(c => new OpenExpenseClaimViewModel( + c.SubmissionId, + c.Description, + c.ClaimDate, + c.GrossCost, + c.Tax, + c.CostCentre, + c.Status)) + .ToList(); + } + + public sealed class ExpenseClaimSubmissionModel + { + [Required] + [StringLength(200)] + [Display(Name = "Description")] + public string Description { get; set; } = string.Empty; + + [Required] + [DataType(DataType.Date)] + [Display(Name = "Claim Date")] + public DateOnly? ClaimDate { get; set; } + + [Required] + [Range(typeof(decimal), "0.01", "79228162514264337593543950335")] + [Display(Name = "Gross Cost")] + public decimal? GrossCost { get; set; } + + [Required] + [Range(typeof(decimal), "0.00", "79228162514264337593543950335")] + [Display(Name = "Tax")] + public decimal? Tax { get; set; } + + [Required] + [Display(Name = "Cost Centre")] + public string CostCentre { get; set; } = string.Empty; + } + + public sealed record OpenExpenseClaimViewModel( + string SubmissionId, + string Description, + DateOnly ClaimDate, + decimal GrossCost, + decimal Tax, + string CostCentre, + ExpenseClaimStatus Status); +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml new file mode 100644 index 0000000..74245ce --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml @@ -0,0 +1,45 @@ +@page "/Home" +@model WebApp.Pages.HomeModel +@{ + ViewData["Title"] = "Home"; +} + +
+
+
+
+

Expense Claims App

+

+ This application helps employees submit expense claims and enables assigned managers + to review and approve or reject claims. +

+ +

What You Can Do

+
    +
  • Create claims: Submit expense details including description, date, cost values, and cost centre.
  • +
  • Track open claims: View your submitted and rejected claims that are still open.
  • +
  • Approve assigned claims: If you are an approver, review claims assigned to you and process many at once.
  • +
+ +

How Approval Works

+

+ When a claim is submitted, the service assigns it to an approver using manager lookup. + Approvers see only claims assigned to them on the Approve page. +

+ +

Authorization Rules

+
    +
  • bob is an employee, he can submit expense claims
  • +
  • expense claims have a limit of 1000
  • +
  • alice is bob's manager she can approve or reject bobs expense claims
  • +
+ + +
+
+
+
+ diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml.cs new file mode 100644 index 0000000..6f54817 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Home.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace WebApp.Pages; + +public class HomeModel() : PageModel +{ + +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_Layout.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..726175e --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_Layout.cshtml @@ -0,0 +1,84 @@ + + + + + + + @ViewData["Title"] - WebApp + + + + + + +
+ @RenderBody() +
+ + + + + + +@using Microsoft.AspNetCore.Identity +@inject SignInManager SignInManager +@{ + var isAuthenticated = User.Identity?.IsAuthenticated == true; + var userName = User.Identity?.Name; + + if (isAuthenticated) + { + IdentityUser? knownUser = null; + + if (!string.IsNullOrWhiteSpace(userName)) + { + knownUser = await SignInManager.UserManager.FindByNameAsync(userName); + } + + if (knownUser is null) + { + await SignInManager.SignOutAsync(); + Context.Response.Redirect("/Account/Login"); + return; + } + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_ValidationScriptsPartial.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_ValidationScriptsPartial.cshtml new file mode 100644 index 0000000..16118f8 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/Shared/_ValidationScriptsPartial.cshtml @@ -0,0 +1,4 @@ + + + + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_Layout.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_Layout.cshtml new file mode 100644 index 0000000..139597f --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_Layout.cshtml @@ -0,0 +1,2 @@ + + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewImports.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..c6b4f01 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using WebApp +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@namespace WebApp.Pages + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewStart.cshtml b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..c75368d --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "Shared/_Layout"; +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Program.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Program.cs new file mode 100644 index 0000000..1860e1d --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Program.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Rsk.AuthZen.Client; +using WebApp; +using WebApp.Authorization; +using WebApp.Domain; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("AuthZenIdentity")); + +builder.Services + .AddIdentityApiEndpoints(options => + { + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 6; + }) + .AddEntityFrameworkStores(); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme; + options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ApplicationScheme; +}); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.LoginPath = "/Account/Login"; + options.AccessDeniedPath = "/AccessDenied"; + options.ReturnUrlParameter = "returnUrl"; +}); + +builder.Services.AddAuthorization(); +builder.Services.AddRazorPages(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +builder.Services.AddHttpClient(); + +builder.Services.Configure(options => +{ + options.AuthorizationUrl = "https://localhost:7064"; +}); +builder.Services.AddTransient(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/", () => Results.Redirect("/Home")); + +app.MapRazorPages(); +app.MapIdentityApi(); + +await SeedUsersAsync(app.Services); + +app.Run(); + +static async Task SeedUsersAsync(IServiceProvider services) +{ + using var scope = services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + await EnsureUserAsync(userManager, "alice", "alice@example.local", "Passw0rd!"); + await EnsureUserAsync(userManager, "bob", "bob@example.local", "Passw0rd!"); +} + +static async Task EnsureUserAsync(UserManager userManager, string userName, string email, string password) +{ + var existing = await userManager.FindByNameAsync(userName); + if (existing is not null) + { + return; + } + + var user = new IdentityUser + { + UserName = userName, + Email = email, + EmailConfirmed = true, + Id = userName + }; + + + var result = await userManager.CreateAsync(user, password); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + throw new InvalidOperationException($"Failed to seed user '{userName}': {errors}"); + } + +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Properties/launchSettings.json b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Properties/launchSettings.json new file mode 100644 index 0000000..3744f5e --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7255;http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md b/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md new file mode 100644 index 0000000..486689f --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md @@ -0,0 +1,23 @@ +# WebApp + +This app uses ASP.NET Core Identity with an in-memory EF Core store and includes a Razor Pages sign-in flow. + +## Seeded users + +- `alice` / `Passw0rd!` +- `bob` / `Passw0rd!` + +## Run + +```zsh +cd /Users/andyclymer/git/AuthZenExample/WebApp +dotnet run +``` + +Then open the printed local URL and go to `/Account/Login`. + +## Notes + +- User data is in-memory only; restarting the app resets users and sessions. +- `/secure` requires authentication. + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/WebApp.csproj b/Samples/CSharp/PolicyDrivenExpenses/WebApp/WebApp.csproj new file mode 100644 index 0000000..a71b3af --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/WebApp.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.Development.json b/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.json b/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From c479a55d0c45bd043684e97baf2d8dc3c51b1bd5 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 10 Apr 2026 14:37:08 +0100 Subject: [PATCH 2/5] Added location of where to get an Enforcer license key --- .../CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs index 2763256..3ba4562 100644 --- a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/Program.cs @@ -15,7 +15,7 @@ public static void Main(string[] args) .AddEnforcer("acmeCorp.global",options => { options.Licensee = "DEMO"; - options.LicenseKey = "eyJhdXRoIjoiREVNTyIsImV4cCI6IjIwMjYtMDQtMzBUMDA6MDA6MDAiLCJpYXQiOiIyMDI2LTAzLTMwVDExOjUwOjA1LjQxMDU5NTFaIiwib3JnIjoiREVNTyIsImF1ZCI6N30=.mtYt37KGtQFJ5je0XJGckWrOx6lqCF5QwraMPJyGFgzYOq8sAFARoIjCKpJ0JpbpCRbCcaTFFhekfHU6NLvJka/ZzfsOYM4JHBSQpol2Z38PwkR4p8J6ONBi/SYOIvXrTk48Tf09Tvo2WHeoiZ9/MLu4IN7+w8sib0fUdkt/cY1PKHHzofBBHPsOT4/LOyxZoVIFLsINC5IOCkGf1vkmCADVFTszOY5nwUf3CNBs+C6UwfpHnvggnMnZpanW45WoWDDcQHgxwS13LgH6k+0XBUrPdcFhTR9mlSuboDspctvVeNASUBWcSLLdGY7GhK2RAWEAf9bbsTrSHErqIK+gx0XcDaq+n94q/qW3swJGGjUlcj+PaGPhmoEojYfwFWWZU6y4dz45XC941GpsYZEGYSVos5+oJMdreCOZqoPXhjEiqmRDgNT7llQ4bixr9voW3N1WKrfy6Ftr2ZYPv/tSOZb3wofGkpLSpPAw/XiyWUOkIiuVajR9CM8//pWQCOZodL1/xuXlioW8EVECXoGDhreDaGhc5BIEycJC/Fv0rgrnFxrPbStm8z+jmigGhN7G7quXaZr+VHhr+WEgjqbB3MSUhR1f/jwjKtiQMEoU7EDiC9BsNkV+KmGKr+o23HlvM2mwE5/rOa/ORgJ3LZmad2yBi6CYge8lwmSLWABMEmc="; + options.LicenseKey = "Get a free license from https://www.identityserver.com/products/enforcer"; }) .AddPolicyEnforcementPoint(o => o.Bias = PepBias.Deny) .AddAuthZen() From 9942f3c7a006cd81b3da351cdcb37acfb47c7f32 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Mon, 13 Apr 2026 12:53:54 +0100 Subject: [PATCH 3/5] Fixed comments on PR --- .../AuthZenPolicyServer/AuthZenPolicyServer.csproj | 6 ------ .../AuthZenPolicyServer/SubjectAttributeProvider.cs | 12 ++++++++---- .../AuthZenAuthorizeExpenseClaimActions.cs | 9 +++++++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj index 92cfa97..4ea04e3 100644 --- a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/AuthZenPolicyServer.csproj @@ -15,10 +15,4 @@ - - - - ..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\10.0.3\Microsoft.AspNetCore.dll - - diff --git a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs index 4404532..90f8ef5 100644 --- a/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs +++ b/Samples/CSharp/PolicyDrivenExpenses/AuthZenPolicyServer/SubjectAttributeProvider.cs @@ -17,8 +17,9 @@ public class SubjectAttributeProvider : RecordAttributeValueProvider GetRecordValue(IAttributeResolver attributeResolver, CancellationToken ct) + + protected override async Task GetRecordValue(IAttributeResolver attributeResolver, + CancellationToken ct) { IReadOnlyCollection? identifiers = await attributeResolver .Resolve(Rsk.Enforcer.Oasis.Attributes.Subject.Identifier, ct); @@ -26,8 +27,11 @@ protected override async Task GetRecordValue(IAttributeResolver string? identifier = identifiers.SingleOrDefault(); if (identifier == null) return null!; - AcmeCorpPerson person = new AcmeCorpPerson(); + if (people.TryGetValue(identifier, out AcmeCorpPerson? person)) + { + return person; + } - return people[identifier]; + return null!; } } \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs index 5182c1e..86a1d1b 100644 --- a/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/Authorization/AuthZenAuthorizeExpenseClaimActions.cs @@ -97,8 +97,13 @@ private async Task Authorize(AuthZenEvaluationRequest request) if (success == false) { - string? error = - JsonSerializer.Deserialize(response.Context).GetProperty("error").GetString(); + string? error = null; + + if (JsonSerializer.Deserialize(response.Context) + .TryGetProperty("error", out JsonElement errorProperty)) + { + error = errorProperty.GetString(); + } return new AuthorizeResult(false, [error ?? String.Empty]); } From 5ae785d8fefeb2e500fe7ff8687e03c9b5685504 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 14 Apr 2026 11:25:56 +0100 Subject: [PATCH 4/5] Updated readme --- Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md b/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md index 486689f..47d66bc 100644 --- a/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md +++ b/Samples/CSharp/PolicyDrivenExpenses/WebApp/README.md @@ -10,14 +10,11 @@ This app uses ASP.NET Core Identity with an in-memory EF Core store and includes ## Run ```zsh -cd /Users/andyclymer/git/AuthZenExample/WebApp dotnet run ``` -Then open the printed local URL and go to `/Account/Login`. +Then and open https://localhost:7255 ## Notes - - User data is in-memory only; restarting the app resets users and sessions. -- `/secure` requires authentication. From 5046269235d3ef38c385031993c7380277cee2f3 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 14 Apr 2026 17:59:09 +0100 Subject: [PATCH 5/5] Removing unneccessary files --- .gitignore | 6 ++---- .../.idea.PolicyDrivenExpenses/.idea/.gitignore | 15 --------------- .../.idea/copilot.data.migration.agent.xml | 6 ------ .../.idea/copilot.data.migration.ask.xml | 6 ------ .../.idea/copilot.data.migration.ask2agent.xml | 6 ------ .../.idea/copilot.data.migration.edit.xml | 6 ------ .../.idea/encodings.xml | 4 ---- .../.idea/indexLayout.xml | 8 -------- .../.idea.PolicyDrivenExpenses/.idea/vcs.xml | 7 ------- .../PolicyDrivenExpenses.sln.DotSettings.user | 16 ---------------- 10 files changed, 2 insertions(+), 78 deletions(-) delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml delete mode 100644 Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user diff --git a/.gitignore b/.gitignore index ca8de4d..41e5106 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,7 @@ riderModule.iml /_ReSharper.Caches/ **/.DS_Store -src/CSharp/.idea/.idea.Rsk.AuthZen/.idea/ - +.idea +*.DotSettings.user src/Typescript/dist/ src/Typescript/node_modules - -src/CSharp/.idea/ diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore deleted file mode 100644 index a3db6dc..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/.idea.AuthZenExample.iml -/modules.xml -/projectSettingsUpdater.xml -/contentModel.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml deleted file mode 100644 index 4ea72a9..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml deleted file mode 100644 index 7ef04e2..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml deleted file mode 100644 index 1f2ea11..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.ask2agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml deleted file mode 100644 index 8648f94..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/copilot.data.migration.edit.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml deleted file mode 100644 index 7b08163..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml b/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml deleted file mode 100644 index ba43f69..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/.idea/.idea.PolicyDrivenExpenses/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user b/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user deleted file mode 100644 index 0f4cf32..0000000 --- a/Samples/CSharp/PolicyDrivenExpenses/PolicyDrivenExpenses.sln.DotSettings.user +++ /dev/null @@ -1,16 +0,0 @@ - - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded \ No newline at end of file