diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..97ba6db --- /dev/null +++ b/License.txt @@ -0,0 +1,26 @@ +OData-WebApiAuthorization + +Copyright (c) Microsoft. All rights reserved. + +Material in this repository is made available under the following terms: + 1. Code is licensed under the MIT license, reproduced below. + 2. Documentation is licensed under the Creative Commons Attribution 3.0 United States (Unported) License. + The text of the license can be found here: http://creativecommons.org/licenses/by/3.0/legalcode + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +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. + diff --git a/README.md b/README.md index 0f62898..f50e23b 100644 --- a/README.md +++ b/README.md @@ -1 +1,180 @@ -# OData WebApi Authorization extensions \ No newline at end of file +# OData WebApi Authorization extensions + +This library uses the permissions defined in the [capability annotations](https://github.com/oasis-tcs/odata-vocabularies/blob/master/vocabularies/Org.OData.Capabilities.V1.md) of the OData model to apply authorization policies +to an OData service based on `Microsoft.AspNetCore.OData`. + +## Usage + +In your `Startup.cs` file: + +```c# +using Microsoft.AspNetCore.OData.Authorization +``` +```c# +public void ConfigureServices(IServiceCollection services) +{ + // odata authorization services + services.AddOData() + .AddODataAuthorization(options => { + // you need to register an authentication scheme/handler + // This works similar to services.AddAuthentication + options.ConfigureAuthentication("DefaultAuthScheme").AddScheme(/* ... */) + }); + + service.AddRouting(); +} +``` +```c# +public void Configure(IApplicationBuilder app, IWebHostEnvironment env) +{ + app.UseRouting(); + // OData register authorization middleware + app.UseOdataAuthorization(); + + app.UseEndpoints(endpoints => { + endpoints.MapODataRoute("odata", "odata", GetEdmModel()); + }); +} +``` + +## Sample applications + +- [ODataAuthorizationSample](./samples/ODataAuthorizationSample): Simple API with permission restrictions and OData authorization middleware set up with a custom authentication handler +- [CookieAuthenticationSample](./samples/CookieAuthenticationSample): Basic API with permissions restrictions and a cookie-based authentication handler + +### How to specify permission scopes? + +By default, the library will try extract permissions from the +authenticated user's claims. Specifically, it will look for +claims with the key `Scope`. If your app is storing user scopes differently (e.g. using a different key), you can provider a scope finder delegate that returns a list of scopes from the current user: + +```c# +services.AddODataAuthorization(options => { + options.ScopeFinder = (context) => { + var scopesClaim = context.User?.FindFirst("Permissions"); + return Task.FromResult(scopes.Value.Split(" ").AsEnumerable()); + }; + + options.ConfigureAuthentication().AddJWTAuthenticationScheme(); +}) +``` + +For a complete working example, check [the sample application](samples/ODataAuthorizationSample). + +## How permissions are applied + +On each request, the library extracts from the model the permissions restrictions that should apply to the route being accessed and creates an authorization policy based on those permissions. Deeper down the request pipeline, the AspNetCore filter-based authorization system will call the OData authorization handler to verify whether the current user's permissions match the ones required by the policy. + +**Note**: If there are not permission restrictions defined for an some target (entity set/singleton/operation) in the model, then endpoints to that target will be authorized by default regardless of the user's permissions. + +### Example permission scopes + +For the following examples, let's assume that we are working with an OData model that has the following scopes defined: + + +Permission scope name | Where it's defined +--------------------------|-------------------- +`Customers.Read` | `ReadRestrictions` of `Customers` entity set +`Customers.ReadByKey` | `ReadByKeyRestrictions` of `Customers` +`Customers.Insert` | `InsertRestrictions` of `Customers` +`Customers.Delete` | `DeleteRestrictions` of `Customers` +`Customers.Update` | `UpdateRestrictions` of `Customers` +`CustomerOrders.Read` | `ReadRestrictions` of `NavigationRestrictions` of `Customers` on `Orders` property +`CustomerOrders.ReadByKey` | `ReadByKeyRestriction` of `NavigationRestrictions` of `Customers` on `Orders` property +`CustomerOrders.Insert` | `InsertRestrictions` of `NavigationRestrictions` of `Customers` on `Orders` property +`CustomerOrders.Update` | `UpdateRestrictions` of `NavigatonRestrictions` of `Customers` on `Orders` property +`CustomerOrders.Delete` | `UpdateRestrictions` of `NavigationRestrictions` of `Customers` on `Orders` property +`Orders.Read` | `ReadRestrictions` of `Orders` entity set +`Orders.ReadByKey` | `ReadByKeyRestrictions` of `Orders` +`Orders.Update` | `UpdateRestrictions` of `Orders` +`Orders.Delete` | `DeleteRestrictions` of `Orders` +`Orders.Insert` | `InsertRestrictions` of `Orders` +`Order.CalculateTax` | `OperationRestrictions` of `CalculateTax` bound function +`UpdateTaxRate` | `OperationRestrictions` of `UpdateTaxRate` unbound action +`TopProduct.Read` | `ReadRestrictions` of `TopProduct` singleton + +### CRUD operations on entity sets and singleton + +For CRUD operations on entity sets and singleton, the permissions of the corresponding insert/update/delete/read restrictions are applied. + +Endpoint | Required permission scopes +-----------------------------|---------------------- +`GET Customers` | `Customers.Read` +`GET Customers(1)` | `Customers.Read` OR Customers.ReadByKey` +`DELETE Customers/1` | `Customers.Delete` +`POST Customers` | `Customers.Insert` +`PUT Customers` | `Customers.Update` +`PATCH Customers` | `Customers.Update` + +Note, in the case of `Customers(1)`, permissions will be extracted from two places if available. Permissions will be extracted from both `ReadRestrictions` +as well as the `ReadByKeyRestrictions` property of the `ReadRestrictions`. If the user has any of the permissions defined in either the `ReadRestrictions` or +`ReadByKeyRestrictions`, then access will be granted. + +For example, if the model defines permission scopes `Customers.Read` in the `ReadRestrictions`, and `Customers.ReadByKey` in the `ReadByKeyRestrictions`, then access to the `GET Customers(1)` endpoint will be granted to uers with either the `Customers.Read` or `Customers.ReadByKey` permissions. + +### Function and Action calls + +The `OperationRestricitons` of the function or action are applied. For function and action imports, the `OperationRestrictions` of the underlying function/action are applied. + +Endpoint | Required permission scopes +----------------------------|----------------------- +`GET Orders(1)/CalculateTax` | `Order.CalculateTax` +`POST UpdateTaxRate` | `UpdateTaxRate` + +**Note**: If functions are overloaded, the operation restrictions of the specific overload being called will apply. + +### Operations on properties + +The `ReadRestrictions` or `UpdateRestrictions` of the entity or singleton whose property are being accessed are applied. + +Endpoint | Restrictions applied +---------------------------------|---------------------- +`GET Customers(1)/Address/City` | `Customers.Read OR Customers.ReadByKey` +`GET TopProduct/Price` | `TopProduct.Read` +`DELETE or PUT or POST Customers(1)/Email` | `Customers.Update` + +### Operations on navigation property links + +These apply the `ReadRestrictions` and `UpdateRestrictions` of the entity/singleton that contains the navigation property where the link is read/added/removed/modified. + +Endpoint | Restrictions applied +---------------------------------|---------------------- +`GET Customers(1)/Orders/$ref` | `Customers.Read OR Customers.ReadByKey` +`GET TopCustomer/Orders/$ref` | `TopProduct.Read` +`DELETE or PUT or POST Customers(1)/Orders/$ref` | `Customers.Update` + +### Navigation properties + +If the endpoint accesses a navigation properties and nested paths in general, the authorization middleware +will check whether the user has permissions to access each segment of the path. + +Given the endpoint `GET Customers(1)/Orders`, the middleware will check whether the user +has read access to Customers(1) and then read access to Orders. The permissions that are checked for +reading Customers(1) are extracted from the `ReadRestrictions` (including `ReadByKeyRestrictions`) of +the `Customers` entity set. The permissions checked for Orders are extracted from both the `ReadRestrictions` +of `Orders` and the `ReadRestrictions` of the `NavigationRestrictions` of `Customers` that apply to the `Orders` +property (`NS.EntityContainer.Customers/{key}/Orders` path). + +Assuming the model defines the scopes `Customers.Read`, `Customers.ReadByKey`, `CustomersOrders.Read` and `Orders.Read`, +the required scopes to read the endpoint would be: + +``` +(Customers.Read OR Customers.ReadByKey) AND (CustomerOrders.Read OR Orders.Read) +``` + +Endpoint | Restrictions applied +-----------------------------|-------------------------- +`GET Customers(1)/Orders` | `(Customers.Read OR Customers.ReadByKey) AND (CustomerOrders.Read OR Orders.Read))` +`GET Customers(1)/Orders(1)/Price`| `(Customers.Read OR Customers.ReadByKey) AND (CustomerOrders.Read OR CustomerOrders.ReadByKey OR Orders.Read OR Orders.ReadByKey)` +`DELETE Customers(1)/Orders(1)` | `(Customers.Update) AND (CustomerOrders.Delete OR Orders.Delete)` +`PUT Customers(1)/Orders(1)` | `(Customers.Update) AND (CustomerOrders.Update or Orders.Update)` +`POST Customers(1)/Orders` | `(Customers.Update) AND (CustomerOrders.Insert or Orders.Insert)` +`GET Customers(1)/Orders(2)/Product` | `(Customers.Read OR Customers.ReadByKey) AND (CustomerOrders.Read OR CustomerOrders.ReadByKey OR Orders.Read OR Orders.ReadByKey) AND (OrderProduct.Read OR OrderProduct.ReadByKey OR Products.Read)` + +Note that a POST, PUT, PATCH or DELETE access to a navigation property is considered an update access to the entity that the navigation property belongs to. + + +## Limitations +- Only supports AspNetCore APIs using endpoing routing, i.e. AspNetCore 3.1 +- Does not support [`RestrictedProperties`](https://github.com/oasis-tcs/odata-vocabularies/blob/master/vocabularies/Org.OData.Capabilities.V1.md#scopetype) +- Permissions are extracted from the model on each request, no caching is performed. It's not clear whether it's guaranteed that the model will not change after startup. \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..d05a78d --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,287 @@ +name: $(TeamProject)_$(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r) + +trigger: + branches: + include: + - master + +pr: +- master + +# Nightly using schedules +schedules: +- cron: "0 0 * * Mon,Tue,Wed,Thu,Fri" + displayName: midnightly build + branches: + include: + - master + # remove authorization branch from schedule after it has been merged + - authorization + +pool: + vmImage: 'windows-latest' + +variables: + BuildPlatform: 'Any CPU' + BuildConfiguration: 'Release' + TargetFramework: 'netcoreapp3.1' + snExe: 'C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\sn.exe' + snExe64: 'C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\sn.exe' + ProductName: 'Microsoft.AspNetCore.OData.Authorization' + ProductDir: $(Build.SourcesDirectory)\src\$(ProductName) + TestDir: $(Build.SourcesDirectory)\test\$(ProductName).Tests + ProductBinPath: '$(ProductDir)\bin\$(BuildConfiguration)\$(TargetFramework)' + TestBinPath: '$(TestDir)\bin\$(BuildConfiguration)\$(TargetFramework)' + mainDll: '$(ProductName).dll' + testDll: '$(ProductName).Tests.dll' + skipComponentGovernanceDetection: true + +steps: + +- task: PoliCheck@1 + displayName: 'Run PoliCheck ".\src"' + inputs: + inputType: CmdLine + cmdLineArgs: '/F:$(Build.SourcesDirectory)/src /T:9 /Sev:"1|2" /PE:2 /O:poli_result_src.xml' + +- task: PoliCheck@1 + displayName: 'Run PoliCheck ".\test"' + inputs: + inputType: CmdLine + cmdLineArgs: '/F:$(Build.SourcesDirectory)/test /T:9 /Sev:"1|2" /PE:2 /O:poli_result_test.xml' + + +# Install the nuget tooler. +- task: NuGetToolInstaller@0 + displayName: 'Use NuGet >=5.2.0' + inputs: + versionSpec: '>=5.2.0' + checkLatest: true + +# Build the Product project +- task: DotNetCoreCLI@2 + displayName: 'build $(ProductName).csproj ' + inputs: + projects: '$(ProductDir)\$(ProductName).csproj' + arguments: '--configuration $(BuildConfiguration) --no-incremental' + +# Build the Unit test project +- task: DotNetCoreCLI@2 + displayName: 'build $(ProductName) Unit test project' + inputs: + projects: '$(TestDir)\$(ProductName).Tests.csproj' + arguments: '--configuration $(BuildConfiguration) --no-incremental' + +# because the assemblies are delay-signed, we need to disable +# strong name validation so that the tests may run, +# otherwise our assemblies will fail to load +- task: Powershell@2 + displayName: 'Skip strong name validation' + inputs: + targetType: 'inline' + script: | + & "$(snExe)" /Vr $(ProductBinPath)\$(mainDll) + & "$(snExe64)" /Vr $(ProductBinPath)\$(mainDll) + & "$(snExe)" /Vr $(TestBinPath)\$(testDll) + & "$(snExe64)" /Vr $(TestBinPath)\$(testDll) + enabled: false + +# Run the Unit test +- task: DotNetCoreCLI@2 + displayName: 'Unit Tests ($(ProductName).Tests.csproj) ' + inputs: + command: test + projects: '$(TestDir)\$(ProductName).Tests.csproj' + arguments: '--configuration $(BuildConfiguration) --no-build' + +# CredScan +- task: securedevelopmentteam.vss-secure-development-tools.build-task-credscan.CredScan@2 + displayName: 'Run CredScan - Src' + inputs: + toolMajorVersion: 'V2' + scanFolder: '$(Build.SourcesDirectory)\src' + debugMode: false + +- task: securedevelopmentteam.vss-secure-development-tools.build-task-credscan.CredScan@2 + displayName: 'Run CredScan - Test' + inputs: + toolMajorVersion: 'V2' + scanFolder: '$(Build.SourcesDirectory)\test' + debugMode: false + +- task: EsrpCodeSigning@1 + displayName: 'ESRP CodeSign - Product Signing' + inputs: + ConnectedServiceName: 'ESRP CodeSigning - OData' + FolderPath: '$(ProductBinPath)' + Pattern: '$(mainDll)' + signConfigType: inlineSignParams + inlineOperation: | + [ +   { +     "keyCode": "MSSharedLibSnKey", +     "operationSetCode": "StrongNameSign", +     "parameters": null, +     "toolName": "sn.exe", +     "toolVersion": "V4.6.1586.0" +   }, +   { +     "keyCode": "MSSharedLibSnKey", +     "operationSetCode": "StrongNameVerify", +     "parameters": null, +     "toolName": "sn.exe", +     "toolVersion": "V4.6.1586.0" +   }, +   { +     "keyCode": "CP-230012", +     "operationSetCode": "SigntoolSign", +     "parameters": [ +     { +       "parameterName": "OpusName", +       "parameterValue": "TestSign" +     }, +     { +       "parameterName": "OpusInfo", +       "parameterValue": "http://test" +     }, +     { +       "parameterName": "PageHash", +       "parameterValue": "/NPH" +     }, +     { +       "parameterName": "TimeStamp", +       "parameterValue": "/t \"http://ts4096.gtm.microsoft.com/TSS/AuthenticodeTS\"" +     } +     ], +     "toolName": "signtool.exe", +     "toolVersion": "6.2.9304.0" +   }, +   { +     "keyCode": "CP-230012", +     "operationSetCode": "SigntoolSign", +     "parameters": [ +     { +       "parameterName": "OpusName", +       "parameterValue": "TestSign" +     }, +     { +       "parameterName": "OpusInfo", +       "parameterValue": "http://test" +     }, +     { +       "parameterName": "Append", +       "parameterValue": "/AS" +     }, +     { +       "parameterName": "PageHash", +       "parameterValue": "/NPH" +     }, +     { +       "parameterName": "FileDigest", +       "parameterValue": "/fd sha256" +     }, +     { +       "parameterName": "TimeStamp", +       "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" +     } +     ], +     "toolName": "signtool.exe", +     "toolVersion": "6.2.9304.0" +   }, +   { +     "keyCode": "CP-230012", +     "operationSetCode": "SigntoolVerify", +     "parameters": [ +     { +       "parameterName": "VerifyAll", +       "parameterValue": "/all" +     } + ], +     "toolName": "signtool.exe", +     "toolVersion": "6.2.9304.0" +   } + ] + VerboseLogin: true + +- task: CopyFiles@2 + displayName: 'Copy Files - Stage Product' + inputs: + SourceFolder: '$(ProductBinPath)' + Contents: '$(mainDll)' + TargetFolder: '$(Build.ArtifactStagingDirectory)\Product' + +# this makes it possible to download the built dll as an artifact +- task: PublishBuildArtifacts@1 + displayName: 'Publish Artifact - Product' + inputs: + PathtoPublish: '$(Build.ArtifactStagingDirectory)\Product' + ArtifactName: 'Product' + publishLocation: 'Container' + +## NuGet Packages + +- task: MSBuild@1 + displayName: 'Get Nuget Package Metadata' + inputs: + solution: '$(Build.SourcesDirectory)\build\GetNugetPackageMetadata.proj' + platform: '$(BuildPlatform)' + configuration: '$(BuildConfiguration)' + +# pack nightly NuGet package +- task: NuGetCommand@2 + condition: and(always(), eq(variables['Build.Reason'], 'Schedule')) # only run for scheduled (nightly) builds + displayName: 'NuGet - pack Microsoft.AspNetCore.OData.Authorization.Nightly.nuspec ' + inputs: + command: custom + arguments: 'pack $(ProductDir)\$(ProductName).Nightly.nuspec -NonInteractive -OutputDirectory $(Build.ArtifactStagingDirectory)\Nuget -Properties Configuration=$(BuildConfiguration);ProductRoot=$(ProductBinPath);SourcesRoot=$(Build.SourcesDirectory);VersionFullSemantic=$(VersionFullSemantic);NightlyBuildVersion=$(VersionNugetNightlyBuild);VersionNuGetSemantic=$(VersionNuGetSemantic);ODataWebApiPackageDependency="$(ODataWebApiPackageDependency)" -Verbosity Detailed -Symbols -Symbols -SymbolPackageFormat snupkg' + +# pack release NuGet package +- task: NuGetCommand@2 + displayName: 'NuGet - pack Microsoft.AspNetCore.OData.Authorization.Release.nuspec ' + inputs: + command: custom + arguments: 'pack $(ProductDir)\$(ProductName).Release.nuspec -NonInteractive -OutputDirectory $(Build.ArtifactStagingDirectory)\Nuget -Properties Configuration=$(BuildConfiguration);ProductRoot=$(ProductBinPath);SourcesRoot=$(Build.SourcesDirectory);VersionFullSemantic=$(VersionFullSemantic);VersionNuGetSemantic=$(VersionNuGetSemantic);ODataWebApiPackageDependency="$(ODataWebApiPackageDependency)" -Verbosity Detailed -Symbols -Symbols -SymbolPackageFormat snupkg' + +# Sign NuGet packages +- task: EsrpCodeSigning@1 + displayName: 'ESRP CodeSigning Nuget Packages' + inputs: + ConnectedServiceName: 'ESRP CodeSigning - OData' + FolderPath: '$(Build.ArtifactStagingDirectory)\Nuget' + Pattern: '*.nupkg' + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "CP-401405", + "operationSetCode": "NuGetSign", + "parameters": [ ], + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "CP-401405", + "operationSetCode": "NuGetVerify", + "parameters": [ ], + "toolName": "sign", + "toolVersion": "1.0" + } + ] + VerboseLogin: true + + +- task: PublishBuildArtifacts@1 + displayName: 'Publish Artifact - Nuget Packages' + inputs: + PathtoPublish: '$(Build.ArtifactStagingDirectory)\Nuget' + ArtifactName: Nuget + +# Push the odata model builder nightly to MyGet +- task: NuGetCommand@2 + condition: and(always(), eq(variables['Build.Reason'], 'Schedule')) # only run for scheduled (nightly) builds + displayName: 'NuGet push - Nightly packages to MyGet' + inputs: + command: push + packagesToPush: '$(Build.ArtifactStagingDirectory)\Nuget\*Nightly*.nupkg' + nuGetFeedType: external + publishFeedCredentials: 'MyGet.org - OData.net new nightly feed' \ No newline at end of file diff --git a/build/GetNugetPackageMetadata.proj b/build/GetNugetPackageMetadata.proj new file mode 100644 index 0000000..86169ed --- /dev/null +++ b/build/GetNugetPackageMetadata.proj @@ -0,0 +1,13 @@ + + + + $([System.DateTime]::Now.ToString("yyyyMMddHHmm")) + + + + + + + + + \ No newline at end of file diff --git a/build/builder.versions.settings.targets b/build/builder.versions.settings.targets new file mode 100644 index 0000000..6caa002 --- /dev/null +++ b/build/builder.versions.settings.targets @@ -0,0 +1,123 @@ + + + + 0 + 1 + 0 + beta + + + + + [7.4.0, 8.0.0) + [4.6.0, 5.0.0) + + + + + 2020 + $([System.Convert]::ToUInt16('$([MSBuild]::Add(1, $([MSBuild]::Subtract($([System.DateTime]::Now.Year), $(VersionStartYear)))))$([System.DateTime]::Now.ToString("MMdd"))')) + $([System.Convert]::ToString($(VersionDateCode))) + + + + + $(VersionMajor).$(VersionMinor).$(VersionBuild) + $(VersionFullSemantic).$(VersionRevision) + + + + + $(VersionFullSemantic) + $(VersionFullSemantic)-$(VersionRelease) + + + + + true + false + + + + 2020 + INVALID_VersionMajor + INVALID_VersionMajor + INVALID_VersionMinor + INVALID_VersionBuild + $([MSBuild]::Add(1, $([MSBuild]::Subtract($([System.DateTime]::Now.Year), $(VersionStartYear)))))$([System.DateTime]::Now.ToString("MMdd")) + 0 + + + + + $(VersionMajor).$(VersionMinor).$(VersionBuild) + $(VersionMajor).$(VersionMinor).$(VersionBuild).$(VersionRevision) + $(AssemblyFileVersion) + $(IntermediateOutputPath)$(MSBuildProjectName).version.cs + + + + + + + + + + + + Microsoft Corporation. + © Microsoft Corporation. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + @(VersionText) + !$(VersionText.Contains('$(AssemblyFileVersion)')) + + + + + + + $([System.Convert]::ToInt16('$(VersionMajor)')) + $([System.Convert]::ToInt16('$(VersionMinor)')) + $([System.Convert]::ToUInt16('$(VersionBuild)')) + $([System.Convert]::ToInt16('$(VersionRevision)')) + + + + + ValidateVersionValues;ShouldGenerateVersionFile;GenerateVersionFileCore + + + + \ No newline at end of file diff --git a/images/odata.png b/images/odata.png new file mode 100644 index 0000000..77f9f57 Binary files /dev/null and b/images/odata.png differ diff --git a/samples/CookieAuthenticationSample/Controllers/AuthController.cs b/samples/CookieAuthenticationSample/Controllers/AuthController.cs new file mode 100644 index 0000000..39e71b4 --- /dev/null +++ b/samples/CookieAuthenticationSample/Controllers/AuthController.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ODataAuthorizationDemo.Models; + +namespace ODataAuthorizationDemo.Controllers +{ + [Route("[controller]")] + [ApiController] + public class AuthController : ControllerBase + { + [HttpPost] + [Route("login")] + public async Task Login([FromBody] LoginData data) + { + // create a claim for each request scope + var claims = data.RequestedScopes.Select(s => new Claim("Scope", s)); + + var claimsIdentity = new ClaimsIdentity( + claims, CookieAuthenticationDefaults.AuthenticationScheme); + + var user = new ClaimsPrincipal(claimsIdentity); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + user); + + return Ok(); + } + + [HttpPost] + [Route("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync( + CookieAuthenticationDefaults.AuthenticationScheme); + + return Ok(); + } + } +} diff --git a/samples/CookieAuthenticationSample/Controllers/ProductsController.cs b/samples/CookieAuthenticationSample/Controllers/ProductsController.cs new file mode 100644 index 0000000..b263a6e --- /dev/null +++ b/samples/CookieAuthenticationSample/Controllers/ProductsController.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; +using Microsoft.AspNet.OData; +using Microsoft.AspNetCore.Mvc; +using ODataAuthorizationDemo.Models; + +namespace ODataAuthorizationDemo.Controllers +{ + public class ProductsController: ODataController + { + private AppDbContext _dbContext; + + public ProductsController(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public IActionResult Get() + { + return Ok(_dbContext.Products); + } + + public IActionResult Get(int key) + { + return Ok(_dbContext.Products.Find(key)); + } + + public async Task Post([FromBody] Product product) + { + _dbContext.Products.Add(product); + await _dbContext.SaveChangesAsync(); + return Ok(product); + } + + public async Task Patch(int key, [FromBody] Delta delta) + { + var product = await _dbContext.Products.FindAsync(key); + delta.Patch(product); + _dbContext.Products.Update(product); + await _dbContext.SaveChangesAsync(); + return Ok(product); + } + + public async Task Delete(int key) + { + var product = await _dbContext.Products.FindAsync(key); + _dbContext.Products.Remove(product); + await _dbContext.SaveChangesAsync(); + return Ok(product); + } + } +} diff --git a/samples/CookieAuthenticationSample/CookieAuthenticationSample.csproj b/samples/CookieAuthenticationSample/CookieAuthenticationSample.csproj new file mode 100644 index 0000000..c9e0e61 --- /dev/null +++ b/samples/CookieAuthenticationSample/CookieAuthenticationSample.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + + diff --git a/samples/CookieAuthenticationSample/Models/AppDbContext.cs b/samples/CookieAuthenticationSample/Models/AppDbContext.cs new file mode 100644 index 0000000..36a7c64 --- /dev/null +++ b/samples/CookieAuthenticationSample/Models/AppDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace ODataAuthorizationDemo.Models +{ + public class AppDbContext : DbContext + { + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Products { get; set; } + } +} diff --git a/samples/CookieAuthenticationSample/Models/AppEdmModel.cs b/samples/CookieAuthenticationSample/Models/AppEdmModel.cs new file mode 100644 index 0000000..8c0d42f --- /dev/null +++ b/samples/CookieAuthenticationSample/Models/AppEdmModel.cs @@ -0,0 +1,31 @@ +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace ODataAuthorizationDemo.Models +{ + public static class AppEdmModel + { + public static IEdmModel GetModel() + { + var builder = new ODataConventionModelBuilder(); + var products = builder.EntitySet("Products"); + + products.HasReadRestrictions() + .HasPermissions(p => + p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Read"))) + .HasReadByKeyRestrictions(r => r.HasPermissions(p => + p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.ReadByKey")))); + + products.HasInsertRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Create"))); + + products.HasUpdateRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Update"))); + + products.HasDeleteRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Delete"))); + + return builder.GetEdmModel(); + } + } +} diff --git a/samples/CookieAuthenticationSample/Models/LoginData.cs b/samples/CookieAuthenticationSample/Models/LoginData.cs new file mode 100644 index 0000000..0673dc6 --- /dev/null +++ b/samples/CookieAuthenticationSample/Models/LoginData.cs @@ -0,0 +1,7 @@ +namespace ODataAuthorizationDemo.Models +{ + public class LoginData + { + public string[] RequestedScopes { get; set; } + } +} diff --git a/samples/CookieAuthenticationSample/Models/Product.cs b/samples/CookieAuthenticationSample/Models/Product.cs new file mode 100644 index 0000000..d7f6825 --- /dev/null +++ b/samples/CookieAuthenticationSample/Models/Product.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ODataAuthorizationDemo.Models +{ + public class Product + { + public int Id { get; set; } + public string Name { get; set; } + public int Price { get; set; } + } +} diff --git a/samples/CookieAuthenticationSample/Program.cs b/samples/CookieAuthenticationSample/Program.cs new file mode 100644 index 0000000..3a303ee --- /dev/null +++ b/samples/CookieAuthenticationSample/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ODataAuthorizationDemo +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/samples/CookieAuthenticationSample/Properties/launchSettings.json b/samples/CookieAuthenticationSample/Properties/launchSettings.json new file mode 100644 index 0000000..7827601 --- /dev/null +++ b/samples/CookieAuthenticationSample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:65163", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "odata", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "ODataAuthorizationDemo": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "odata", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/CookieAuthenticationSample/README.md b/samples/CookieAuthenticationSample/README.md new file mode 100644 index 0000000..c2a13e2 --- /dev/null +++ b/samples/CookieAuthenticationSample/README.md @@ -0,0 +1,40 @@ +# OData WebApi Authorization Cookie authentication demo + +This application demonstrates how to use OData WebApi Authorization with a cookie-based authentication scheme. + +The OData API has a single entity set `Products` and supports the basic CRUD requests. The model +has been annotated with permission restrictions for these CRUD operations: + + +| Endpoint | Required permissions +---------------------------|---------------------- +`GET /odata/Products` | `Product.Read` +`GET /odata/Products/1` | `Product.Read` or `Product.ReadByKey` +`DELETE /odata/Products/1`| `Product.Delete` +`POST /odata/Products` | `Product.Insert` +`PATCH /odata/Products(1)` | `Product.Update` + +In order to access an endpoint, you will need to authenticate with the right scopes. + +An `POST /auth/login` endpoint exists to allow you to authenticate yourself and specify the scopes you want the authenticated user to have. + +For example, in order to authenticate with read and insert permissions on the `Products` entity set: + +``` +POST /auth/login +``` +Body: +```json +{ + "RequestedScopes": ["Product.Insert", "Product.Read"] +} +``` + +To logout, make the following request: + +``` +POST /auth/logout +``` + +When you access an endpoint without the right permissions, it might return a `404` error instead of the expected `403`. This is because +the cookie authentication handler tries to redirect to a login page by default. No such page exists in this demo, hence the `404` error. diff --git a/samples/CookieAuthenticationSample/Startup.cs b/samples/CookieAuthenticationSample/Startup.cs new file mode 100644 index 0000000..80ee064 --- /dev/null +++ b/samples/CookieAuthenticationSample/Startup.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.AspNetCore.OData.Authorization.Extensions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ODataAuthorizationDemo.Models; +using Microsoft.AspNetCore.OData.Authorization; + +namespace ODataAuthorizationDemo +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(opt => opt.UseInMemoryDatabase("ODataAuthDemo")); + + services.AddOData() + .AddAuthorization(options => + { + options.ScopesFinder = context => + { + var scopes = context.User.FindAll("Scope").Select(claim => claim.Value); + return Task.FromResult(scopes); + }; + + options.ConfigureAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); + }); + + services.AddRouting(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseAuthentication(); + + app.UseODataAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.Expand().Filter().Count().OrderBy(); + endpoints.MapODataRoute("odata", "odata", AppEdmModel.GetModel()); + }); + } + } +} diff --git a/samples/CookieAuthenticationSample/appsettings.Development.json b/samples/CookieAuthenticationSample/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/samples/CookieAuthenticationSample/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/CookieAuthenticationSample/appsettings.json b/samples/CookieAuthenticationSample/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/samples/CookieAuthenticationSample/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/ODataAuthorizationSample/Controllers/CustomersController.cs b/samples/ODataAuthorizationSample/Controllers/CustomersController.cs new file mode 100644 index 0000000..8c39f57 --- /dev/null +++ b/samples/ODataAuthorizationSample/Controllers/CustomersController.cs @@ -0,0 +1,161 @@ +using AspNetCore3ODataPermissionsSample.Models; +using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Routing; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace AspNetCore3ODataPermissionsSample.Controllers +{ + public class CustomersController : ODataController + { + private readonly AppDbContext _context; + + public CustomersController(AppDbContext context) + { + _context = context; + + if (!_context.Customers.Any()) + { + IList customers = new List + { + new Customer + { + Name = "Jonier", + HomeAddress = new Address { City = "Redmond", Street = "156 AVE NE"}, + FavoriteAddresses = new List
+ { + new Address { City = "Redmond", Street = "256 AVE NE"}, + new Address { City = "Redd", Street = "56 AVE NE"}, + }, + Order = new Order { Title = "104m" }, + Orders = Enumerable.Range(0, 2).Select(e => new Order { Title = "abc" + e }).ToList() + }, + new Customer + { + Name = "Sam", + HomeAddress = new Address { City = "Bellevue", Street = "Main St NE"}, + FavoriteAddresses = new List
+ { + new Address { City = "Red4ond", Street = "456 AVE NE"}, + new Address { City = "Re4d", Street = "51 NE"}, + }, + Order = new Order { Title = "Zhang" }, + Orders = Enumerable.Range(0, 2).Select(e => new Order { Title = "xyz" + e }).ToList() + }, + new Customer + { + Name = "Peter", + HomeAddress = new Address { City = "Hollewye", Street = "Main St NE"}, + FavoriteAddresses = new List
+ { + new Address { City = "R4mond", Street = "546 NE"}, + new Address { City = "R4d", Street = "546 AVE"}, + }, + Order = new Order { Title = "Jichan" }, + Orders = Enumerable.Range(0, 2).Select(e => new Order { Title = "ijk" + e }).ToList() + }, + }; + + foreach (var customer in customers) + { + _context.Customers.Add(customer); + _context.Orders.Add(customer.Order); + _context.Orders.AddRange(customer.Orders); + } + + _context.SaveChanges(); + } + } + + [EnableQuery] + public IActionResult Get() + { + // NOTE: without the NoTracking setting, the query for $select=HomeAddress will throw an exception + // A tracking query projects owned entity without corresponding owner in result. Owned entities cannot be tracked without their owner... + _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + + return Ok(_context.Customers); + } + + [EnableQuery] + public IActionResult Get(int key) + { + return Ok(_context.Customers.FirstOrDefault(c => c.Id == key)); + } + + /// + /// If testing in IISExpress with the POST request to: http://localhost:2087/test/my/a/Customers + /// Content-Type : application/json + /// { + /// "Name": "Jonier"," + /// } + /// + /// Check the reponse header, you can see + /// "Location" : "http://localhost:2087/test/my/a/Customers(0)" + /// + [EnableQuery] + public IActionResult Post([FromBody]Customer customer) + { + return Created(customer); + } + + public IActionResult Delete(int key) + { + var customer = _context.Customers.FirstOrDefault(c => c.Id == key); + _context.Customers.Remove(customer); + return Ok(customer); + } + + [ODataRoute("GetTopCustomer")] + public IActionResult GetTopCustomer() + { + return Ok(_context.Customers.FirstOrDefault()); + } + + [ODataRoute("Customers({key})/GetAge")] + public IActionResult GetAge(int key) + { + return Ok(_context.Customers.Find(key).Id + 20); + } + + [ODataRoute("Customers({key})/Orders")] + public IActionResult GetCustomerOrders(int key) + { + return Ok(_context.Customers.Find(key).Orders); + } + + [ODataRoute("Customers({key})/Orders({relatedKey})")] + public IActionResult GetCustomerOrder(int key, int relatedKey) + { + return Ok(_context.Customers.Find(key).Orders.FirstOrDefault(o => o.Id == relatedKey)); + } + + [ODataRoute("Customers({key})/Orders({relatedKey})/Title")] + public IActionResult GetOrderTitleByKey(int key, int relatedKey) + { + return Ok(_context.Customers.Find(key)?.Orders.FirstOrDefault(o => o.Id == relatedKey)?.Title); + } + + [ODataRoute("Customers({key})/Orders({relatedKey})/$ref")] + public IActionResult GetCustomerOrderByKeyRef(int key, int relatedKey) + { + return Ok(_context.Customers.Find(key)?.Orders.FirstOrDefault(o => o.Id == relatedKey)); + } + + [ODataRoute("Customers({key})/Order")] + public IActionResult GetOrder(int key) + { + return Ok(_context.Customers.Find(key).Order); + } + + [ODataRoute("Customers({key})/Order/Title")] + public IActionResult GetOrderTitle(int key) + { + return Ok(_context.Customers.Find(key)?.Order?.Title); + } + } +} diff --git a/samples/ODataAuthorizationSample/Models/Address.cs b/samples/ODataAuthorizationSample/Models/Address.cs new file mode 100644 index 0000000..a8f72e1 --- /dev/null +++ b/samples/ODataAuthorizationSample/Models/Address.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace AspNetCore3ODataPermissionsSample.Models +{ + [Owned, ComplexType] + public class Address + { + public string City { get; set; } + + public string Street { get; set; } + } +} diff --git a/samples/ODataAuthorizationSample/Models/AppDbContext.cs b/samples/ODataAuthorizationSample/Models/AppDbContext.cs new file mode 100644 index 0000000..863b247 --- /dev/null +++ b/samples/ODataAuthorizationSample/Models/AppDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +namespace AspNetCore3ODataPermissionsSample.Models +{ + public class AppDbContext : DbContext + { + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Customers { get; set; } + + public DbSet Orders { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(c => c.HomeAddress).WithOwner(); + } + } +} diff --git a/samples/ODataAuthorizationSample/Models/AppModel.cs b/samples/ODataAuthorizationSample/Models/AppModel.cs new file mode 100644 index 0000000..8e7f802 --- /dev/null +++ b/samples/ODataAuthorizationSample/Models/AppModel.cs @@ -0,0 +1,65 @@ + +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Vocabularies; +using Microsoft.OData.ModelBuilder; + +namespace AspNetCore3ODataPermissionsSample.Models +{ + public class AppModel + { + public static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + var customers = builder.EntitySet("Customers"); + var orders = builder.EntitySet("Orders"); + var getTopCustomer = builder.Function("GetTopCustomer").ReturnsFromEntitySet("Customers"); + + var customerEntity = builder.EntityType(); + var getAge = customerEntity.Function("GetAge").Returns(); + + // define permission restrictions + customers.HasReadRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.Read"))) + .HasReadByKeyRestrictions(r => r.HasPermissions(p => + p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.ReadByKey")))); + + customers.HasInsertRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.Insert"))); + + customers.HasUpdateRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.Update"))); + + customers.HasDeleteRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.Delete"))); + + getTopCustomer.HasOperationRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.GetTop"))); + + getAge.HasOperationRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.GetAge"))); + + orders.HasReadRestrictions() + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Orders.Read"))) + .HasReadByKeyRestrictions(r => r.HasPermissions(p => + p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Orders.ReadByKey")))); + + customers.HasNavigationRestrictions() + .HasRestrictedProperties(props => props + .HasNavigationProperty(new EdmNavigationPropertyPathExpression("Customers/Orders")) + .HasReadRestrictions(r => r + .HasPermissions(p => p.HasSchemeName("Sheme").HasScopes(s => s.HasScope("Customers.ReadOrders"))) + .HasReadByKeyRestrictions(r => r.HasPermissions(p => + p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.ReadOrderByKey")))))) + .HasRestrictedProperties(props => props + .HasNavigationProperty(new EdmNavigationPropertyPathExpression("Customers/Order")) + .HasReadRestrictions(r => r + .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Customers.ReadOrder"))))); + + + var model = builder.GetEdmModel(); + + return model; + } + } +} diff --git a/samples/ODataAuthorizationSample/Models/Customer.cs b/samples/ODataAuthorizationSample/Models/Customer.cs new file mode 100644 index 0000000..8896b85 --- /dev/null +++ b/samples/ODataAuthorizationSample/Models/Customer.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace AspNetCore3ODataPermissionsSample.Models +{ + public class Customer + { + public int Id { get; set; } + + public string Name { get; set; } + + public virtual Address HomeAddress { get; set; } + + public virtual IList
FavoriteAddresses { get; set; } + + public virtual Order Order { get; set; } + + public virtual IList Orders { get; set; } + } + +} diff --git a/samples/ODataAuthorizationSample/Models/Order.cs b/samples/ODataAuthorizationSample/Models/Order.cs new file mode 100644 index 0000000..07efcc3 --- /dev/null +++ b/samples/ODataAuthorizationSample/Models/Order.cs @@ -0,0 +1,8 @@ +namespace AspNetCore3ODataPermissionsSample.Models +{ + public class Order + { + public int Id { get; set; } + public string Title { get; set; } + } +} diff --git a/samples/ODataAuthorizationSample/ODataAuthorizationSample.csproj b/samples/ODataAuthorizationSample/ODataAuthorizationSample.csproj new file mode 100644 index 0000000..2ce9848 --- /dev/null +++ b/samples/ODataAuthorizationSample/ODataAuthorizationSample.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + + + diff --git a/samples/ODataAuthorizationSample/Program.cs b/samples/ODataAuthorizationSample/Program.cs new file mode 100644 index 0000000..f50dcf7 --- /dev/null +++ b/samples/ODataAuthorizationSample/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AspNetCore3ODataPermissionsSample +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/samples/ODataAuthorizationSample/Properties/launchSettings.json b/samples/ODataAuthorizationSample/Properties/launchSettings.json new file mode 100644 index 0000000..efd1119 --- /dev/null +++ b/samples/ODataAuthorizationSample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:24571", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "odata", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "AspNetCore3ODataPermissionsSample": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "odata", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/ODataAuthorizationSample/README.md b/samples/ODataAuthorizationSample/README.md new file mode 100644 index 0000000..6b484fc --- /dev/null +++ b/samples/ODataAuthorizationSample/README.md @@ -0,0 +1,31 @@ +# OData WebApi Authorization sample + +This application demonstrates how to use the OData authorization extensions to apply permissions to OData endpoints based on the model capability restrictions. + +The application defines a model with CRUD permission restrictions annotations on the `Customers` and `Orders` entity sets, the +`GetTopCustomer` unbound function and `GetAge` bound function. + +It uses a custom authentication handler that assumes a +user is always authenticated. This handler extracts the permissions from a header called `Permissions`, which +is a comma-separated list of allowed scopes. + +Based on the model annotations, the: + +| Endpoint | Required permissions +---------------------------|---------------------- +`GET /odata/Customers` | `Customers.Read` +`GET /odata/Customers/1` | `Customers.Read` or `Customers.ReadByKey` +`DELETE /odata/Customers/1`| `Customers.Delete` +`POST /odata/Customers` | `Customers.Insert` +`GET /odata/Customers(1)/GetAge` | `Customers.GetAge` +`GET /odata/GetTopCustomer`| `Customers.GetTop` +`GET /odata/Customers(1)/Orders` | (`Customers.Read` or `Customers.ReadByKey`) and (`Orders.Read` or `Customers.ReadOrders`) +`GET /odata/Customers(1)/Orders(1)` | `(Customers.Read` or `Customers.ReadByKey`) and (`Orders.Read` or `Orders.ReadByKey` or `Customers.Read` or `Customer.ReadByKey`) +`GET /odata/Customers(1)/Orders(1)/Title` | `(Customers.Read` or `Customers.ReadByKey`) and (`Orders.Read` or `Orders.ReadByKey` or `Customers.Read` or `Customer.ReadByKey`) +`GET /odata/Customers(1)/Orders/(1)/$ref` | `(Customers.Read` or `Customers.ReadByKey`) +`GET /odata/Customers(1)/Order` | (`Customers.Read` or `Customers.ReadByKey`) and (`Customers.ReadOrder` or `Orders.Read`) +`GET /odata/Customers(1)/Order/Title` | (`Customers.Read` or `Customers.ReadByKey`) and (`Customers.ReadOrder` or `Orders.Read`) + +To test the app, run it and open Postman. In Postman +add a header called `Permissions` and any of the permissions +specified above in a comma-separated list (e.g. `Customers.Read, Customers.Insert`), then make requests to the endpoints above. If you hit an endpoint without adding its required permissions to the header, you should get a `403 Forbidden` error. diff --git a/samples/ODataAuthorizationSample/Startup.cs b/samples/ODataAuthorizationSample/Startup.cs new file mode 100644 index 0000000..976cd1d --- /dev/null +++ b/samples/ODataAuthorizationSample/Startup.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using AspNetCore3ODataPermissionsSample.Models; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AspNetCore3ODataPermissionsSample +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(opt => opt.UseLazyLoadingProxies().UseInMemoryDatabase("CustomerOrderList")); + services.AddOData(); + + // add OData authorization services + services.AddODataAuthorization((options) => + { + // we setup a custom scope finder that will extract the user's scopes from the Permission claims + options.ScopesFinder = (context) => + { + var permissions = context.User?.FindAll("Permission"); + if (permissions == null) + { + return Task.FromResult(Enumerable.Empty()); + } + + return Task.FromResult(permissions.Select(p => p.Value)); + }; + + options.ConfigureAuthentication("AuthScheme") + .AddScheme("AuthScheme", options => { }); + }); + + // OData authorization depends on the AspNetCore authentication and authorization services + // we need to specify at least one authentication scheme and handler. Here we opt for a simple custom handler defined + // later in this file, for demonstration purposes. Could also use cookie-based or JWT authentication + //services.AddAuthentication("AuthScheme") + // .AddScheme("AuthScheme", options => { }); + //services.AddAuthorization(); + + + services.AddRouting(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseAuthentication(); + // add OData authorization middleware + // we don't need to UseAuthorization() if we don't need to handle authorizaiton for non-odata routes + app.UseODataAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.Expand().Filter().Count().OrderBy(); + endpoints.MapODataRoute("odata", "odata", AppModel.GetEdmModel()); + }); + } + } + + // our customer authentication handler + internal class CustomAuthenticationHandler : AuthenticationHandler + { + public CustomAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var identity = new System.Security.Principal.GenericIdentity("Me"); + // in this dummy authentication scheme, we assume that the permissions granted + // to the user are stored as a comma-separate list in a header called Permissions + var scopeValues = Request.Headers["Permissions"]; + if (scopeValues.Count != 0) + { + var scopes = scopeValues.ToArray()[0].Split(",").Select(s => s.Trim()); + var claims = scopes.Select(scope => new Claim("Permission", scope)); + identity.AddClaims(claims); + } + + var principal = new GenericPrincipal(identity, Array.Empty()); + // we use the same auhentication scheme as the one specified in the OData model permissions + var ticket = new AuthenticationTicket(principal, "AuthScheme"); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + internal class CustomAuthenticationOptions : AuthenticationSchemeOptions + { + } +} diff --git a/samples/ODataAuthorizationSample/appsettings.Development.json b/samples/ODataAuthorizationSample/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/samples/ODataAuthorizationSample/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/ODataAuthorizationSample/appsettings.json b/samples/ODataAuthorizationSample/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/samples/ODataAuthorizationSample/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/sln/WebApiAuthorization.sln b/sln/WebApiAuthorization.sln index 00ff119..1a9282e 100644 --- a/sln/WebApiAuthorization.sln +++ b/sln/WebApiAuthorization.sln @@ -3,9 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29806.167 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNet.OData.Authorization", "..\src\Microsoft.AspNet.OData.Authorization\Microsoft.AspNet.OData.Authorization.csproj", "{C5584F3A-1D56-404C-B77D-B488419F9DCF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OData.Authorization", "..\src\Microsoft.AspNetCore.OData.Authorization\Microsoft.AspNetCore.OData.Authorization.csproj", "{76FAA150-248D-4FDB-AC14-45B4012FC5E0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNet.OData.Authorization.Tests", "..\test\Microsoft.AspNet.OData.Authorization.Tests\Microsoft.AspNet.OData.Authorization.Tests.csproj", "{4006B90C-924A-407D-BEAD-F229699BA552}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OData.Authorization.Tests", "..\test\Microsoft.AspNetCore.OData.Authorization.Tests\Microsoft.AspNetCore.OData.Authorization.Tests.csproj", "{7A3995FF-56C7-4336-AC14-759FE6620EED}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ODataAuthorizationSample", "..\samples\ODataAuthorizationSample\ODataAuthorizationSample.csproj", "{9A41F458-49A0-4B6B-8916-92D7A57CB449}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CookieAuthenticationSample", "..\samples\CookieAuthenticationSample\CookieAuthenticationSample.csproj", "{1114A03D-29AA-4F92-8619-BAFF9A399A69}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,14 +17,22 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C5584F3A-1D56-404C-B77D-B488419F9DCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5584F3A-1D56-404C-B77D-B488419F9DCF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5584F3A-1D56-404C-B77D-B488419F9DCF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5584F3A-1D56-404C-B77D-B488419F9DCF}.Release|Any CPU.Build.0 = Release|Any CPU - {4006B90C-924A-407D-BEAD-F229699BA552}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4006B90C-924A-407D-BEAD-F229699BA552}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4006B90C-924A-407D-BEAD-F229699BA552}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4006B90C-924A-407D-BEAD-F229699BA552}.Release|Any CPU.Build.0 = Release|Any CPU + {76FAA150-248D-4FDB-AC14-45B4012FC5E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76FAA150-248D-4FDB-AC14-45B4012FC5E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76FAA150-248D-4FDB-AC14-45B4012FC5E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76FAA150-248D-4FDB-AC14-45B4012FC5E0}.Release|Any CPU.Build.0 = Release|Any CPU + {7A3995FF-56C7-4336-AC14-759FE6620EED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A3995FF-56C7-4336-AC14-759FE6620EED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A3995FF-56C7-4336-AC14-759FE6620EED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A3995FF-56C7-4336-AC14-759FE6620EED}.Release|Any CPU.Build.0 = Release|Any CPU + {9A41F458-49A0-4B6B-8916-92D7A57CB449}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A41F458-49A0-4B6B-8916-92D7A57CB449}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A41F458-49A0-4B6B-8916-92D7A57CB449}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A41F458-49A0-4B6B-8916-92D7A57CB449}.Release|Any CPU.Build.0 = Release|Any CPU + {1114A03D-29AA-4F92-8619-BAFF9A399A69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1114A03D-29AA-4F92-8619-BAFF9A399A69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1114A03D-29AA-4F92-8619-BAFF9A399A69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1114A03D-29AA-4F92-8619-BAFF9A399A69}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Microsoft.AspNet.OData.Authorization/Class1.cs b/src/Microsoft.AspNet.OData.Authorization/Class1.cs deleted file mode 100644 index 9e97dbd..0000000 --- a/src/Microsoft.AspNet.OData.Authorization/Class1.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Microsoft.AspNet.OData.Authorization -{ - public class Class1 - { - public int Add(int x, int y) => x + y; - } -} diff --git a/src/Microsoft.AspNet.OData.Authorization/Microsoft.AspNet.OData.Authorization.csproj b/src/Microsoft.AspNet.OData.Authorization/Microsoft.AspNet.OData.Authorization.csproj deleted file mode 100644 index cb63190..0000000 --- a/src/Microsoft.AspNet.OData.Authorization/Microsoft.AspNet.OData.Authorization.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - netcoreapp3.1 - - - diff --git a/src/Microsoft.AspNetCore.OData.Authorization/BaseScopesCombiner.cs b/src/Microsoft.AspNetCore.OData.Authorization/BaseScopesCombiner.cs new file mode 100644 index 0000000..b3a3e70 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/BaseScopesCombiner.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// An that combines + /// other evaluators and returns the aggregate + /// result of the combined evaluators. + /// + internal abstract class BaseScopesCombiner: IScopesEvaluator + { + public BaseScopesCombiner(params IScopesEvaluator[] evaluators) : this(evaluators.AsEnumerable()) + { } + + public BaseScopesCombiner(IEnumerable permissions) + { + Evaluators = new List(permissions); + } + + protected List Evaluators { get; private set; } + + public void Add(IScopesEvaluator evaluator) + { + Evaluators.Add(evaluator); + } + + public void AddRange(IEnumerable evaluators) + { + Evaluators.AddRange(evaluators); + } + + public abstract bool AllowsScopes(IEnumerable scopes); + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/DefaultScopesEvaluator.cs b/src/Microsoft.AspNetCore.OData.Authorization/DefaultScopesEvaluator.cs new file mode 100644 index 0000000..321cc41 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/DefaultScopesEvaluator.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// An that always returns true. + /// It's useful for operations that have no restrictions explicitly + /// defined in the model. Such operations are assumed to be always + /// allowed regardless of the user's scopes. + /// + internal class DefaultScopesEvaluator : IScopesEvaluator + { + public bool AllowsScopes(IEnumerable scopes) + { + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationBuilderExtensions.cs new file mode 100644 index 0000000..5439c67 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationBuilderExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Provides extension methods for to add OData authorization + /// + public static class ODataAuthorizationBuilderExtensions + { + /// + /// Use OData authorization to handle endpoint permissions based on capability restrictions + /// defined in the model. + /// This only works with endpoint routing. + /// + /// The to use. + /// + public static IApplicationBuilder UseODataAuthorization(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationServiceCollectionExtensions.cs new file mode 100644 index 0000000..43a3895 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataAuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + + /// + /// Provides authorization extensions for + /// + public static class ODataAuthorizationServiceCollectionExtensions + { + + /// + /// Adds OData model-based authorization services to the service collection + /// + /// The service collection + /// + public static IServiceCollection AddODataAuthorization(this IServiceCollection services) + { + return AddODataAuthorization(services, null); + } + + /// + /// Adds OData model-based authorization services to the service collection + /// + /// The service collection + /// Action to configure the authorization options + /// + public static IServiceCollection AddODataAuthorization(this IServiceCollection services, Action configureOptions) + { + var options = new ODataAuthorizationOptions(services); + configureOptions?.Invoke(options); + services.AddSingleton(_ => new ODataAuthorizationHandler(options.ScopesFinder)); + + + if (!options.AuthenticationConfigured) + { + options.ConfigureAuthentication(); + } + + if (!options.AuthorizationConfigured) + { + options.ConfigureAuthorization(); + } + + return services; + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataBuilderExtensions.cs new file mode 100644 index 0000000..ed9b93b --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Extensions/ODataBuilderExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics.Contracts; +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.AspNet.OData.Interfaces; + +namespace Microsoft.AspNetCore.OData.Authorization.Extensions +{ + public static class ODataBuilderExtensions + { + /// + /// Adds OData model-based authorization services + /// + /// + /// + public static IODataBuilder AddAuthorization(this IODataBuilder odataBuilder) + { + Contract.Assert(odataBuilder != null); + + odataBuilder.Services.AddODataAuthorization(); + return odataBuilder; + } + + /// + /// Adds OData model-based authorization services + /// + /// + /// Action to configure the authorization options + /// + public static IODataBuilder AddAuthorization(this IODataBuilder odataBuilder, Action configureODataAuth) + { + Contract.Assert(odataBuilder != null); + + odataBuilder.Services.AddODataAuthorization(configureODataAuth); + return odataBuilder; + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/IScopesEvaluator.cs b/src/Microsoft.AspNetCore.OData.Authorization/IScopesEvaluator.cs new file mode 100644 index 0000000..b42d804 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/IScopesEvaluator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Evaluates whether specified scopes should be allow + /// access to a restricted resource. + /// + internal interface IScopesEvaluator + { + /// + /// Returns true if access should be granted based + /// on the given . + /// + /// + /// + bool AllowsScopes(IEnumerable scopes); + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Nightly.nuspec b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Nightly.nuspec new file mode 100644 index 0000000..79501cd --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Nightly.nuspec @@ -0,0 +1,28 @@ + + + + Microsoft.AspNetCore.OData.Authorization + Microsoft OData Web API Authorization + $VersionFullSemantic$-Nightly$NightlyBuildVersion$ + Microsoft + © Microsoft Corporation. All rights reserved. + This library provides middleware that enforces permissions specified in an OData EDM model on OData ASP.NET Core Web API endpoints. + en-US + http://github.com/OData/WebApiAuthorization + MIT + true + Microsoft OData Authorization WebApi Permissions + images\odata.png + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Release.nuspec b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Release.nuspec new file mode 100644 index 0000000..5d9b03b --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.Release.nuspec @@ -0,0 +1,28 @@ + + + + Microsoft.AspNetCore.OData.Authorization + Microsoft OData Web API Authorization + $VersionNugetSemantic$ + Microsoft + © Microsoft Corporation. All rights reserved. + This library provides middleware that enforces permissions specified in an OData EDM model on OData ASP.NET Core Web API endpoints. + en-US + http://github.com/OData/WebApiAuthorization + MIT + true + Microsoft OData Authorization WebApi Permissions + images\odata.png + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.csproj b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.csproj new file mode 100644 index 0000000..7a5eb96 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Microsoft.AspNetCore.OData.Authorization.csproj @@ -0,0 +1,31 @@ + + + + netcoreapp3.1 + true + true + $(MSBuildThisFileDirectory)\..\..\tools\35MSSharedLib1024.snk + + + + true + full + false + TRACE;DEBUG + + + portable + true + TRACE + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationHandler.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationHandler.cs new file mode 100644 index 0000000..3ba9c2a --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Decides whether an OData request should be authorized or denied. + /// + internal class ODataAuthorizationHandler : AuthorizationHandler + { + private Func>> _scopesFinder; + + /// + /// Creates an instance of . + /// + /// User-defined function used to retrieve the current user's scopes from the authorization context + public ODataAuthorizationHandler(Func>> scopesFinder = null) : base() + { + _scopesFinder = scopesFinder; + } + + /// + /// Makes decision whether authorization should be allowed based on the provided scopes. + /// + /// The authorization context. + /// The defining the scopes required + /// for authorization to succeed. + /// + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ODataAuthorizationScopesRequirement requirement) + { + var scopeFinderContext = new ScopeFinderContext(context.User); + var getScopes = _scopesFinder ?? DefaultFindScopes; + var scopes = await getScopes(scopeFinderContext).ConfigureAwait(false); + + if (requirement.PermissionHandler.AllowsScopes(scopes)) + { + context.Succeed(requirement); + } + } + + private Task> DefaultFindScopes(ScopeFinderContext context) + { + var claims = context.User?.FindAll("Scope"); + var scopes = claims?.Select(c => c.Value) ?? Enumerable.Empty(); + return Task.FromResult(scopes); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationMiddleware.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationMiddleware.cs new file mode 100644 index 0000000..f2230a2 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationMiddleware.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.OData.Edm; +using Microsoft.AspNetCore.Authorization; +using System.Diagnostics.Contracts; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// The OData authorization middleware + /// + public class ODataAuthorizationMiddleware + { + private RequestDelegate _next; + + /// + /// Instantiates a new instance of . + /// + public ODataAuthorizationMiddleware(RequestDelegate next) + { + _next = next; + } + + /// + /// Invoke the middleware. + /// + /// The http context. + /// A task that can be awaited. + public Task Invoke(HttpContext context) + { + Contract.Assert(context != null); + + var odataFeature = context.ODataFeature(); + if (odataFeature == null || odataFeature.Path == null) + { + return _next(context); + } + + IEdmModel model = context.Request.GetModel(); + if (model == null) + { + return _next(context); + } + + var permissions = model.ExtractPermissionsForRequest(context.Request.Method, odataFeature.Path); + ApplyRestrictions(permissions, context); + + return _next(context); + } + + private static void ApplyRestrictions(IScopesEvaluator handler, HttpContext context) + { + var requirement = new ODataAuthorizationScopesRequirement(handler); + var policy = new AuthorizationPolicyBuilder().AddRequirements(requirement).Build(); + + // We use the AuthorizeFilter instead of relying on the built-in authorization middleware + // because we cannot add new metadata to the endpoint in the middle of a request + // and OData's current implementation of endpoint routing does not allow for + // adding metadata to individual routes ahead of time + var authFilter = new AuthorizeFilter(policy); + context.ODataFeature().ActionDescriptor?.FilterDescriptors?.Add(new FilterDescriptor(authFilter, 0)); + } + + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationOptions.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationOptions.cs new file mode 100644 index 0000000..9a346bb --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationOptions.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Provides configuration for the OData authorization layer + /// + public class ODataAuthorizationOptions + { + IServiceCollection _services; + + + public ODataAuthorizationOptions(IServiceCollection services) + { + _services = services; + } + + public bool AuthenticationConfigured { private set; get; } + public bool AuthorizationConfigured { private set; get; } + /// + /// Gets or sets the delegate used to find the scopes granted to the authenticated user + /// from the authorization context. + /// By default the library tries to get scopes from the principal's claims that have "Scope" as the key. + /// + public Func>> ScopesFinder { get; set; } + + public AuthenticationBuilder ConfigureAuthentication() + { + AuthenticationConfigured = true; + return _services.AddAuthentication(); + } + + public AuthenticationBuilder ConfigureAuthentication(string defaultScheme) + { + AuthenticationConfigured = true; + return _services.AddAuthentication(defaultScheme); + } + + public AuthenticationBuilder ConfigureAuthentication(Action configureOptions) + { + AuthenticationConfigured = true; + return _services.AddAuthentication(configureOptions); + } + + public void ConfigureAuthorization() + { + AuthorizationConfigured = true; + _services.AddAuthorization(); + } + + public void ConfigureAuthorization(Action configure) + { + AuthorizationConfigured = true; + _services.AddAuthorization(configure); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationScopesRequirement.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationScopesRequirement.cs new file mode 100644 index 0000000..b26ebc0 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataAuthorizationScopesRequirement.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Authorization; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Authorization requirement specifying the scopes required + /// to authorize an OData request. + /// + internal class ODataAuthorizationScopesRequirement : IAuthorizationRequirement + { + /// + /// Creates an instance of . + /// + /// The scopes required to authorize a request where this requirement is applied. + public ODataAuthorizationScopesRequirement(IScopesEvaluator permissionHandler) + { + PermissionHandler = permissionHandler; + } + + /// + /// The scopes specified by this authorization requirement. + /// + internal IScopesEvaluator PermissionHandler { get; private set; } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataCapabilitiesRestrictionsConstants.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataCapabilitiesRestrictionsConstants.cs new file mode 100644 index 0000000..b8d5d8d --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataCapabilitiesRestrictionsConstants.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.OData.Authorization +{ + internal static class ODataCapabilityRestrictionsConstants + { + public const string CapabilitiesNamespace = "Org.OData.Capabilities.V1"; + public static readonly string ReadRestrictions = $"{CapabilitiesNamespace}.ReadRestrictions"; + public static readonly string ReadByKeyRestrictions = $"{CapabilitiesNamespace}.ReadByKeyRestrictions"; + public static readonly string InsertRestrictions = $"{CapabilitiesNamespace}.InsertRestrictions"; + public static readonly string UpdateRestrictions = $"{CapabilitiesNamespace}.UpdateRestrictions"; + public static readonly string DeleteRestrictions = $"{CapabilitiesNamespace}.DeleteRestrictions"; + public static readonly string OperationRestrictions = $"{CapabilitiesNamespace}.OperationRestrictions"; + public static readonly string NavigationRestrictions = $"{CapabilitiesNamespace}.NavigationRestrictions"; + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ODataModelPermissionsExtractor.cs b/src/Microsoft.AspNetCore.OData.Authorization/ODataModelPermissionsExtractor.cs new file mode 100644 index 0000000..0ce5086 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ODataModelPermissionsExtractor.cs @@ -0,0 +1,549 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Vocabularies; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + internal static class ODataModelPermissionsExtractor + { + internal static IScopesEvaluator ExtractPermissionsForRequest(this IEdmModel model, string method, AspNet.OData.Routing.ODataPath odataPath) + { + var template = odataPath.PathTemplate; + ODataPathSegment prevSegment = null; + + var segments = new List(); + + // this combines the permission scopes across path segments + // with a logical AND + var permissionsChain = new WithAndScopesCombiner(); + + var lastSegmentIndex = odataPath.Segments.Count - 1; + + if (template.EndsWith("$ref", StringComparison.OrdinalIgnoreCase)) + { + // for ref segments, we apply the permission of the entity that contains the navigation property + // e.g. for GET Customers(10)/Products/$ref, we apply the read key permissions of Customers + // for GET TopCustomer/Products/$ref, we apply the read permissions of TopCustomer + // for DELETE Customers(10)/Products(10)/$ref we apply the update permissions of Customers + lastSegmentIndex = odataPath.Segments.Count - 2; + while (!(odataPath.Segments[lastSegmentIndex] is KeySegment || odataPath.Segments[lastSegmentIndex] is SingletonSegment || odataPath.Segments[lastSegmentIndex] is NavigationPropertySegment) + && lastSegmentIndex > 0) + { + lastSegmentIndex--; + } + } + + for (int i = 0; i <= lastSegmentIndex; i++) + { + var segment = odataPath.Segments[i]; + + if (segment is EntitySetSegment || + segment is SingletonSegment || + segment is NavigationPropertySegment || + segment is OperationSegment || + segment is OperationImportSegment || + segment is KeySegment || + segment is PropertySegment) + { + var parent = prevSegment; + var isPropertyAccess = IsNextSegmentOfType(odataPath, i) || + IsNextSegmentOfType(odataPath, i) || + IsNextSegmentOfType(odataPath, i); + prevSegment = segment; + segments.Add(segment); + + // if nested segment, extract navigation restrictions of root + + // else extract entity/set restrictions + if (segment is EntitySetSegment entitySetSegment) + { + + // if Customers(key), then we'll handle it when we reach the key segment + // so that we can properly handle ReadByKeyRestrictions + if (IsNextSegmentKey(odataPath, i)) + { + continue; + } + + // if Customers/UnboundFunction, then we'll handle it when we reach the operation segment + if (IsNextSegmentOfType(odataPath, i)) + { + continue; + } + + IScopesEvaluator permissions; + + permissions = GetNavigationPropertyCrudPermisions( + segments, + false, + model, + method); + + if (permissions is DefaultScopesEvaluator) + { + permissions = GetNavigationSourceCrudPermissions(entitySetSegment.EntitySet, model, method); + } + + var handler = new WithOrScopesCombiner(permissions); + permissionsChain.Add(handler); + } + else if (segment is SingletonSegment singletonSegment) + { + // if Customers/UnboundFunction, then we'll handle it when we reach the operation segment + if (IsNextSegmentOfType(odataPath, i)) + { + continue; + } + + if (isPropertyAccess) + { + var propertyPermissions = GetSingletonPropertyOperationPermissions(singletonSegment.Singleton, model, method); + permissionsChain.Add(new WithOrScopesCombiner(propertyPermissions)); + } + else + { + var permissions = GetNavigationSourceCrudPermissions(singletonSegment.Singleton, model, method); + permissionsChain.Add(new WithOrScopesCombiner(permissions)); + } + } + else if (segment is KeySegment keySegment) + { + // if Customers/UnboundFunction, then we'll handle it when we reach the operation segment + if (IsNextSegmentOfType(odataPath, i)) + { + continue; + } + + var entitySet = keySegment.NavigationSource as IEdmEntitySet; + var permissions = isPropertyAccess ? + GetEntityPropertyOperationPermissions(entitySet, model, method) : + GetEntityCrudPermissions(entitySet, model, method); + + var evaluator = new WithOrScopesCombiner(permissions); + + + if (parent is NavigationPropertySegment) + { + var nestedPermissions = isPropertyAccess ? + GetNavigationPropertyPropertyOperationPermisions(segments, isTargetByKey: true, model, method) : + GetNavigationPropertyCrudPermisions(segments, isTargetByKey: true, model, method); + + evaluator.Add(nestedPermissions); + } + + + permissionsChain.Add(evaluator); + } + else if (segment is NavigationPropertySegment navSegment) + { + // if Customers/UnboundFunction, then we'll handle it when we reach there + if (IsNextSegmentOfType(odataPath, i)) + { + continue; + } + + + // if Customers(key), then we'll handle it when we reach the key segment + // so that we can properly handle ReadByKeyRestrictions + if (IsNextSegmentKey(odataPath, i)) + { + continue; + } + + var topLevelPermissions = GetNavigationSourceCrudPermissions(navSegment.NavigationSource as IEdmVocabularyAnnotatable, model, method); + var segmentEvaluator = new WithOrScopesCombiner(topLevelPermissions); + + var nestedPermissions = GetNavigationPropertyCrudPermisions( + segments, + isTargetByKey: false, + model, + method); + + + segmentEvaluator.Add(nestedPermissions); + permissionsChain.Add(segmentEvaluator); + } + else if (segment is OperationImportSegment operationImportSegment) + { + var annotations = operationImportSegment.OperationImports.First().Operation.VocabularyAnnotations(model); + var permissions = GetOperationPermissions(annotations); + permissionsChain.Add(new WithOrScopesCombiner(permissions)); + } + else if (segment is OperationSegment operationSegment) + { + var annotations = operationSegment.Operations.First().VocabularyAnnotations(model); + var operationPermissions = GetOperationPermissions(annotations); + permissionsChain.Add(new WithOrScopesCombiner(operationPermissions)); + } + } + } + + return permissionsChain; + } + + private static IScopesEvaluator GetNavigationPropertyCrudPermisions(IList pathSegments, bool isTargetByKey, IEdmModel model, string method) + { + if (pathSegments.Count <= 1) + { + return new DefaultScopesEvaluator(); + } + + var expectedPath = GetPathFromSegments(pathSegments); + IEdmVocabularyAnnotatable root = (pathSegments[0] as EntitySetSegment)?.EntitySet as IEdmVocabularyAnnotatable ?? + (pathSegments[0] as SingletonSegment)?.Singleton; + + var navRestrictions = root.VocabularyAnnotations(model).Where(a => a.Term.FullName() == ODataCapabilityRestrictionsConstants.NavigationRestrictions); + foreach (var restriction in navRestrictions) + { + if (restriction.Value is IEdmRecordExpression record) + { + var temp = record.FindProperty("RestrictedProperties"); + if (temp?.Value is IEdmCollectionExpression restrictedProperties) + { + foreach (var item in restrictedProperties.Elements) + { + if (item is IEdmRecordExpression restrictedProperty) + { + var navigationProperty = restrictedProperty.FindProperty("NavigationProperty").Value as IEdmPathExpression; + if (navigationProperty?.Path == expectedPath) + + { + if (method == "GET") + { + var readRestrictions = restrictedProperty.FindProperty("ReadRestrictions")?.Value as IEdmRecordExpression; + var readPermissions = ExtractPermissionsFromRecord(readRestrictions); + var evaluator = new WithOrScopesCombiner(readPermissions); + + if (isTargetByKey) + { + var readByKeyRestrictions = readRestrictions.FindProperty("ReadByKeyRestrictions")?.Value as IEdmRecordExpression; + var readByKeyPermissions = ExtractPermissionsFromRecord(readByKeyRestrictions); + evaluator.AddRange(readByKeyPermissions); + } + + return evaluator; + } + else if (method == "POST") + { + var insertRestrictions = restrictedProperty.FindProperty("InsertRestrictions")?.Value as IEdmRecordExpression; + var insertPermissions = ExtractPermissionsFromRecord(insertRestrictions); + return new WithOrScopesCombiner(insertPermissions); + } + else if (method == "PATCH" || method == "PUT" || method == "PATCH") + { + var updateRestrictions = restrictedProperty.FindProperty("UpdateRestrictions")?.Value as IEdmRecordExpression; + var updatePermissions = ExtractPermissionsFromRecord(updateRestrictions); + return new WithOrScopesCombiner(updatePermissions); + } + else if (method == "DELETE") + { + var deleteRestrictions = restrictedProperty.FindProperty("DeleteRestrictions")?.Value as IEdmRecordExpression; + var deletePermissions = ExtractPermissionsFromRecord(deleteRestrictions); + return new WithOrScopesCombiner(deletePermissions); + } + } + } + } + } + } + } + + return new DefaultScopesEvaluator(); + } + + private static IScopesEvaluator GetNavigationPropertyPropertyOperationPermisions(IList pathSegments, bool isTargetByKey, IEdmModel model, string method) + { + if (pathSegments.Count <= 1) + { + return new DefaultScopesEvaluator(); + } + + var expectedPath = GetPathFromSegments(pathSegments); + IEdmVocabularyAnnotatable root = (pathSegments[0] as EntitySetSegment).EntitySet as IEdmVocabularyAnnotatable ?? (pathSegments[0] as SingletonSegment).Singleton; + + var navRestrictions = root.VocabularyAnnotations(model).Where(a => a.Term.FullName() == ODataCapabilityRestrictionsConstants.NavigationRestrictions); + foreach (var restriction in navRestrictions) + { + if (restriction.Value is IEdmRecordExpression record) + { + var temp = record.FindProperty("RestrictedProperties"); + if (temp?.Value is IEdmCollectionExpression restrictedProperties) + { + foreach (var item in restrictedProperties.Elements) + { + if (item is IEdmRecordExpression restrictedProperty) + { + var navigationProperty = restrictedProperty.FindProperty("NavigationProperty").Value as IEdmPathExpression; + if (navigationProperty?.Path == expectedPath) + + { + if (method == "GET") + { + var readRestrictions = restrictedProperty.FindProperty("ReadRestrictions")?.Value as IEdmRecordExpression; + + var readPermissions = ExtractPermissionsFromRecord(readRestrictions); + var evaluator = new WithOrScopesCombiner(readPermissions); + + if (isTargetByKey) + { + var readByKeyRestrictions = readRestrictions.FindProperty("ReadByKeyRestrictions")?.Value as IEdmRecordExpression; + var readByKeyPermissions = ExtractPermissionsFromRecord(readByKeyRestrictions); + evaluator.AddRange(readByKeyPermissions); + } + + return evaluator; + } + else if (method == "POST" || method == "PATCH" || method == "PUT" || method == "MERGE" || method == "DELETE") + { + var updateRestrictions = restrictedProperty.FindProperty("UpdateRestrictions")?.Value as IEdmRecordExpression; + var updatePermissions = ExtractPermissionsFromRecord(updateRestrictions); + return new WithOrScopesCombiner(updatePermissions); + } + } + } + } + } + } + } + + return new DefaultScopesEvaluator(); + } + + + static bool IsNextSegmentKey(AspNet.OData.Routing.ODataPath path, int currentPos) + { + return IsNextSegmentOfType(path, currentPos); + } + + static bool IsNextSegmentOfType(AspNet.OData.Routing.ODataPath path, int currentPos) + { + var maxPos = path.Segments.Count - 1; + if (maxPos <= currentPos) + { + return false; + } + + var nextSegment = path.Segments[currentPos + 1]; + + if (nextSegment is T) + { + return true; + } + + if (nextSegment is TypeSegment && maxPos >= currentPos + 2 && path.Segments[currentPos + 2] is T) + { + return true; + } + + return false; + } + + static string GetPathFromSegments(IList segments) + { + var pathParts = new List(segments.Count); + var i = 0; + foreach (var path in segments) + { + i++; + + if (path is EntitySetSegment entitySetSegment) + { + pathParts.Add(entitySetSegment.EntitySet.Name); + } + else if(path is SingletonSegment singletonSegment) + { + pathParts.Add(singletonSegment.Singleton.Name); + } + else if(path is NavigationPropertySegment navSegment) + { + pathParts.Add(navSegment.NavigationProperty.Name); + } + } + + return string.Join('/', pathParts); + } + + private static IScopesEvaluator GetSingletonPropertyOperationPermissions(IEdmVocabularyAnnotatable target, IEdmModel model, string method) + { + var annotations = target.VocabularyAnnotations(model); + if (method == "GET") + { + return GetReadPermissions(annotations); + } + else if (method == "PATCH" || method == "PUT" || method == "MERGE" || method == "POST" || method == "DELETE") + { + return GetUpdatePermissions(annotations); + } + + return new DefaultScopesEvaluator(); + } + + private static IScopesEvaluator GetEntityPropertyOperationPermissions(IEdmVocabularyAnnotatable target, IEdmModel model, string method) + { + var annotations = target.VocabularyAnnotations(model); + if (method == "GET") + { + return GetReadByKeyPermissions(annotations); + } + else if (method == "PATCH" || method == "PUT" || method == "MERGE" || method == "POST" || method == "DELETE") + { + return GetUpdatePermissions(annotations); + } + + return new DefaultScopesEvaluator(); + } + + private static IScopesEvaluator GetNavigationSourceCrudPermissions(IEdmVocabularyAnnotatable target, IEdmModel model, string method) + { + var annotations = target.VocabularyAnnotations(model); + if (method == "GET") + { + return GetReadPermissions(annotations); + } + else if (method == "POST") + { + return GetInsertPermissions(annotations); + } + else if (method == "PATCH" || method == "PUT" || method == "MERGE") + { + return GetUpdatePermissions(annotations); + } + else if (method == "DELETE") + { + return GetDeletePermissions(annotations); + } + + return new DefaultScopesEvaluator(); + } + + private static IScopesEvaluator GetEntityCrudPermissions(IEdmVocabularyAnnotatable target, IEdmModel model, string method) + { + var annotations = target.VocabularyAnnotations(model); + + if (method == "GET") + { + return GetReadByKeyPermissions(annotations); + } + else if (method == "PUT" || method == "POST" || method == "MERGE" || method == "PATCH") + { + return GetUpdatePermissions(annotations); + } + else if (method == "DELETE") + { + return GetDeletePermissions(annotations); + } + + return new DefaultScopesEvaluator(); + } + + private static IScopesEvaluator GetReadPermissions(IEnumerable annotations) + { + var permissions = GetPermissions(ODataCapabilityRestrictionsConstants.ReadRestrictions, annotations); + return new WithOrScopesCombiner(permissions); + } + + private static IScopesEvaluator GetReadByKeyPermissions(IEnumerable annotations) + { + var evaluator = new WithOrScopesCombiner(); + foreach (var annotation in annotations) + { + if (annotation.Term.FullName() == ODataCapabilityRestrictionsConstants.ReadRestrictions && annotation.Value is IEdmRecordExpression record) + { + var readPermissions = ExtractPermissionsFromAnnotation(annotation); + evaluator.AddRange(readPermissions); + + var readByKeyProperty = record.FindProperty("ReadByKeyRestrictions"); + var readByKeyValue = readByKeyProperty?.Value as IEdmRecordExpression; + var permissionsProperty = readByKeyValue?.FindProperty("Permissions"); + var readByKeyPermissions = ExtractPermissionsFromProperty(permissionsProperty); + evaluator.AddRange(readByKeyPermissions); + } + } + + return evaluator; + } + + private static IScopesEvaluator GetInsertPermissions(IEnumerable annotations) + { + var permissions = GetPermissions(ODataCapabilityRestrictionsConstants.InsertRestrictions, annotations); + return new WithOrScopesCombiner(permissions); + } + + private static IScopesEvaluator GetDeletePermissions(IEnumerable annotations) + { + var permissions = GetPermissions(ODataCapabilityRestrictionsConstants.DeleteRestrictions, annotations); + return new WithOrScopesCombiner(permissions); + } + + private static IScopesEvaluator GetUpdatePermissions(IEnumerable annotations) + { + var permissions = GetPermissions(ODataCapabilityRestrictionsConstants.UpdateRestrictions, annotations); + return new WithOrScopesCombiner(permissions); + } + + private static IScopesEvaluator GetOperationPermissions(IEnumerable annotations) + { + var permissions = GetPermissions(ODataCapabilityRestrictionsConstants.OperationRestrictions, annotations); + return new WithOrScopesCombiner(permissions); + } + + private static IEnumerable GetPermissions(string restrictionType, IEnumerable annotations) + { + foreach (var annotation in annotations) + { + if (annotation.Term.FullName() == restrictionType) + { + return ExtractPermissionsFromAnnotation(annotation); + } + } + + return Enumerable.Empty(); + } + + private static IEnumerable ExtractPermissionsFromAnnotation(IEdmVocabularyAnnotation annotation) + { + return ExtractPermissionsFromRecord(annotation.Value as IEdmRecordExpression); + } + + private static IEnumerable ExtractPermissionsFromRecord(IEdmRecordExpression record) + { + var permissionsProperty = record?.FindProperty("Permissions"); + return ExtractPermissionsFromProperty(permissionsProperty); + } + + private static IEnumerable ExtractPermissionsFromProperty(IEdmPropertyConstructor permissionsProperty) + { + if (permissionsProperty?.Value is IEdmCollectionExpression permissionsValue) + { + return permissionsValue.Elements.OfType().Select(p => GetPermissionData(p)); + } + + return Enumerable.Empty(); + } + + private static PermissionData GetPermissionData(IEdmRecordExpression permissionRecord) + { + var schemeProperty = permissionRecord.FindProperty("SchemeName")?.Value as IEdmStringConstantExpression; + var scopesProperty = permissionRecord.FindProperty("Scopes")?.Value as IEdmCollectionExpression; + + var scopes = scopesProperty.Elements.Select(s => GetScopeData(s as IEdmRecordExpression)); + + return new PermissionData() { SchemeName = schemeProperty.Value, Scopes = scopes.ToList() }; + } + + private static PermissionScopeData GetScopeData(IEdmRecordExpression scopeRecord) + { + var scopeProperty = scopeRecord.FindProperty("Scope")?.Value as IEdmStringConstantExpression; + var restrictedPropertiesProperty = scopeRecord.FindProperty("RestrictedProperties")?.Value as IEdmStringConstantExpression; + + return new PermissionScopeData() { Scope = scopeProperty?.Value, RestrictedProperties = restrictedPropertiesProperty?.Value }; + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/PermissionData.cs b/src/Microsoft.AspNetCore.OData.Authorization/PermissionData.cs new file mode 100644 index 0000000..24a6935 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/PermissionData.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Represents permission restrictions extracted from an OData model. + /// + internal class PermissionData: IScopesEvaluator + { + public string SchemeName { get; set; } + public IList Scopes { get; set; } + + public bool AllowsScopes(IEnumerable scopes) + { + var allowedScopes = Scopes.Select(s => s.Scope); + return allowedScopes.Intersect(scopes).Any(); + } + } + + internal class PermissionScopeData + { + public string Scope { get; set; } + public string RestrictedProperties { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.OData.Authorization/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a73ecd9 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("Microsoft.AspNetCore.OData.Authorization.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.Authorization/ScopeFinderContext.cs b/src/Microsoft.AspNetCore.OData.Authorization/ScopeFinderContext.cs new file mode 100644 index 0000000..0d456a9 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/ScopeFinderContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Security.Claims; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Contains information used to extract permission scopes + /// available to the authenticated user + /// + public class ScopeFinderContext + { + /// + /// Creates an instance of + /// + /// The authenticated user + public ScopeFinderContext(ClaimsPrincipal user) + { + User = user; + } + + /// + /// The representing the current user. + /// + public ClaimsPrincipal User { get; private set; } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/WithAndScopesCombiner.cs b/src/Microsoft.AspNetCore.OData.Authorization/WithAndScopesCombiner.cs new file mode 100644 index 0000000..df7fe8f --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/WithAndScopesCombiner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Combines s with a logical AND: returns true + /// iff all evaluators return true or if evaluators are empty. + /// + internal class WithAndScopesCombiner: BaseScopesCombiner + { + + public WithAndScopesCombiner(params IScopesEvaluator[] permissions) : base(permissions) + { } + + public WithAndScopesCombiner(IEnumerable permissions) : base(permissions) + { } + + public override bool AllowsScopes(IEnumerable scopes) + { + return Evaluators.All(permissions => permissions.AllowsScopes(scopes)); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.Authorization/WithOrScopesCombiner.cs b/src/Microsoft.AspNetCore.OData.Authorization/WithOrScopesCombiner.cs new file mode 100644 index 0000000..ca4aa5b --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.Authorization/WithOrScopesCombiner.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.Authorization +{ + /// + /// Combines s using a logical OR: returns + /// true if any of the evaluators return true or if there + /// are no evualtors added to the combiner. + /// + internal class WithOrScopesCombiner: BaseScopesCombiner + { + public WithOrScopesCombiner(params IScopesEvaluator[] evaluators) : base(evaluators) + { } + + public WithOrScopesCombiner(IEnumerable evaluators) : base(evaluators) + { } + + public override bool AllowsScopes(IEnumerable scopes) + { + if (!Evaluators.Any()) + { + return true; + } + + return Evaluators.Any(permission => permission.AllowsScopes(scopes)); + } + } +} diff --git a/test/Microsoft.AspNet.OData.Authorization.Tests/Microsoft.AspNet.OData.Authorization.Tests.csproj b/test/Microsoft.AspNet.OData.Authorization.Tests/Microsoft.AspNet.OData.Authorization.Tests.csproj deleted file mode 100644 index 85fbd4d..0000000 --- a/test/Microsoft.AspNet.OData.Authorization.Tests/Microsoft.AspNet.OData.Authorization.Tests.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - netcoreapp3.1 - - false - - - - - - - - - - - - - - diff --git a/test/Microsoft.AspNet.OData.Authorization.Tests/UnitTest1.cs b/test/Microsoft.AspNet.OData.Authorization.Tests/UnitTest1.cs deleted file mode 100644 index a9cf66b..0000000 --- a/test/Microsoft.AspNet.OData.Authorization.Tests/UnitTest1.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Xunit; -using Microsoft.AspNet.OData.Authorization; - -namespace Microsoft.AspNet.OData.Authorization.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - var test = new Class1(); - Assert.Equal(3, test.Add(1, 2)); - } - } -} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockAssembly.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockAssembly.cs new file mode 100644 index 0000000..7affb9d --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockAssembly.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Abstractions +{ + public sealed class MockAssembly : Assembly + { + Type[] _types; + + public MockAssembly(params Type[] types) + { + _types = types; + } + + public MockAssembly(params MockType[] types) + { + foreach (var type in types) + { + type.SetupGet(t => t.Assembly).Returns(this); + } + _types = types.Select(t => t.Object).ToArray(); + } + + /// + /// AspNet uses GetTypes as opposed to DefinedTypes() + /// + public override Type[] GetTypes() + { + return _types; + } + + /// + /// AspNetCore uses DefinedTypes as opposed to GetTypes() + /// + public override IEnumerable DefinedTypes + { + get { return _types.AsEnumerable().Select(a => a.GetTypeInfo()); } + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockPropertyInfo.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockPropertyInfo.cs new file mode 100644 index 0000000..d741da6 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockPropertyInfo.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using Moq; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Abstractions +{ + public sealed class MockPropertyInfo : Mock + { + private readonly Mock _mockGetMethod = new Mock(); + private readonly Mock _mockSetMethod = new Mock(); + + public static implicit operator PropertyInfo(MockPropertyInfo mockPropertyInfo) + { + return mockPropertyInfo.Object; + } + + public MockPropertyInfo() + : this(typeof(object), "P") + { + } + + public MockPropertyInfo(Type propertyType, string propertyName) + { + SetupGet(p => p.DeclaringType).Returns(typeof(object)); + SetupGet(p => p.ReflectedType).Returns(typeof(object)); + SetupGet(p => p.Name).Returns(propertyName); + SetupGet(p => p.PropertyType).Returns(propertyType); + SetupGet(p => p.CanRead).Returns(true); + SetupGet(p => p.CanWrite).Returns(true); + Setup(p => p.GetGetMethod(It.IsAny())).Returns(_mockGetMethod.Object); + Setup(p => p.GetSetMethod(It.IsAny())).Returns(_mockSetMethod.Object); + Setup(p => p.Equals(It.IsAny())).Returns(p => ReferenceEquals(Object, p)); + + _mockGetMethod.SetupGet(m => m.Attributes).Returns(MethodAttributes.Public); + } + + public MockPropertyInfo Abstract() + { + _mockGetMethod.SetupGet(m => m.Attributes) + .Returns(_mockGetMethod.Object.Attributes | MethodAttributes.Abstract); + + return this; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockType.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockType.cs new file mode 100644 index 0000000..d4f4853 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/MockType.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Moq; +using Moq.Protected; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Abstractions +{ + public sealed class MockType : Mock + { + public static implicit operator Type(MockType mockType) + { + return mockType.Object; + } + + private readonly List _propertyInfos = new List(); + private MockType _baseType; + + public MockType() + : this("T") + { + } + + public MockType(string typeName, bool hasDefaultCtor = true, string @namespace = "DefaultNamespace") + { + SetupGet(t => t.Name).Returns(typeName); + SetupGet(t => t.BaseType).Returns(typeof(Object)); + SetupGet(t => t.Assembly).Returns(typeof(object).Assembly); + Setup(t => t.GetProperties(It.IsAny())) + .Returns(() => _propertyInfos.Union(_baseType != null ? _baseType._propertyInfos : Enumerable.Empty()).Select(p => p.Object).ToArray()); + Setup(t => t.Equals(It.IsAny())).Returns(t => ReferenceEquals(Object, t)); + Setup(t => t.ToString()).Returns(typeName); + Setup(t => t.Namespace).Returns(@namespace); + Setup(t => t.IsAssignableFrom(It.IsAny())).Returns(true); + Setup(t => t.FullName).Returns(@namespace + "." + typeName); + + TypeAttributes(System.Reflection.TypeAttributes.Class | System.Reflection.TypeAttributes.Public); + + + if (hasDefaultCtor) + { + this.Protected() + .Setup( + "GetConstructorImpl", + BindingFlags.Instance | BindingFlags.Public, + ItExpr.IsNull(), + CallingConventions.Standard | CallingConventions.VarArgs, + Type.EmptyTypes, + ItExpr.IsNull()) + .Returns(new Mock().Object); + } + } + + public MockType TypeAttributes(TypeAttributes typeAttributes) + { + this.Protected() + .Setup("GetAttributeFlagsImpl") + .Returns(typeAttributes); + + return this; + } + + public MockType BaseType(MockType mockBaseType) + { + _baseType = mockBaseType; + SetupGet(t => t.BaseType).Returns(mockBaseType); + Setup(t => t.IsSubclassOf(mockBaseType)).Returns(true); + + return this; + } + + public MockType Property(string propertyName) + { + Property(typeof(T), propertyName); + + return this; + } + + public MockType Property(Type propertyType, string propertyName, params Attribute[] attributes) + { + var mockPropertyInfo = new MockPropertyInfo(propertyType, propertyName); + mockPropertyInfo.SetupGet(p => p.DeclaringType).Returns(this); + mockPropertyInfo.SetupGet(p => p.ReflectedType).Returns(this); + mockPropertyInfo.Setup(p => p.GetCustomAttributes(It.IsAny())).Returns(attributes); + + _propertyInfos.Add(mockPropertyInfo); + + return this; + } + + public MockPropertyInfo GetProperty(string name) + { + return _propertyInfos.Single(p => p.Object.Name == name); + } + + public MockType AsCollection() + { + var mockCollectionType = new MockType(); + + mockCollectionType.Setup(t => t.GetInterfaces()).Returns(new Type[] { typeof(IEnumerable<>).MakeGenericType(this) }); + + return mockCollectionType; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/ServerFactory.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/ServerFactory.cs new file mode 100644 index 0000000..78969aa --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Abstractions/ServerFactory.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Net.Http; +using System.Reflection; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Abstractions +{ + /// + /// Factory for creating a test servers. + /// + public class TestServerFactory + { + + /// + /// Create a TestServer that uses endpoint routing. + /// + /// The controllers to use. + /// The endpoints configuration action. + /// The service collection configuration action. + /// The app builder configuration action. + /// This can be used to add additional middleware before the endpoints middleware. + /// An TestServer. + public static TestServer CreateWithEndpointRouting( + Type[] controllers, + Action configureEndpoints, + Action configureService = null, + Action configureBuilder = null) + { + IWebHostBuilder builder = WebHost.CreateDefaultBuilder(); + builder.ConfigureServices(services => + { + services.AddOData(); + configureService?.Invoke(services); + }); + + builder.Configure(app => + { + app.Use(next => context => + { + var body = context.Features.Get(); + if (body != null) + { + body.AllowSynchronousIO = true; + } + + return next(context); + }); + + app.UseODataBatching(); + app.UseRouting(); + configureBuilder?.Invoke(app); + app.UseEndpoints((endpoints) => + { + + var appBuilder = endpoints.CreateApplicationBuilder(); + ApplicationPartManager applicationPartManager = appBuilder.ApplicationServices.GetRequiredService(); + applicationPartManager.ApplicationParts.Clear(); + + if (controllers != null) + { + AssemblyPart part = new AssemblyPart(new MockAssembly(controllers)); + applicationPartManager.ApplicationParts.Add(part); + } + + // Insert a custom ControllerFeatureProvider to bypass the IsPublic restriction of controllers + // to allow for nested controllers which are excluded by the built-in ControllerFeatureProvider. + applicationPartManager.FeatureProviders.Clear(); + applicationPartManager.FeatureProviders.Add(new TestControllerFeatureProvider()); + + configureEndpoints(endpoints); + }); + }); + + return new TestServer(builder); + } + + /// + /// Create an HttpClient from a server. + /// + /// The TestServer. + /// An HttpClient. + public static HttpClient CreateClient(TestServer server) + { + return server.CreateClient(); + } + + private class TestControllerFeatureProvider : ControllerFeatureProvider + { + /// + /// + /// Identical to ControllerFeatureProvider.IsController except for the typeInfo.IsPublic check. + /// + protected override bool IsController(TypeInfo typeInfo) + { + if (!typeInfo.IsClass) + { + return false; + } + + if (typeInfo.IsAbstract) + { + return false; + } + + if (typeInfo.ContainsGenericParameters) + { + return false; + } + + if (typeInfo.IsDefined(typeof(NonControllerAttribute))) + { + return false; + } + + if (!typeInfo.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) && + !typeInfo.IsDefined(typeof(ControllerAttribute))) + { + return false; + } + + return true; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Extensions/HttpContentExtensions.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Extensions/HttpContentExtensions.cs new file mode 100644 index 0000000..6d40847 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Extensions/HttpContentExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + + +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Extensions +{ + /// + /// Extensions for HttpContent. + /// + public static class HttpContentExtensions + { + /// + /// Get the content as the value of ObjectContent. + /// + /// The content value. + public static string AsObjectContentValue(this HttpContent content) + { + string json = content.ReadAsStringAsync().Result; + try + { + JObject obj = JsonConvert.DeserializeObject(json); + return obj["value"].ToString(); + } + catch (JsonReaderException) + { + } + + return json; + } + + /// + /// A custom extension for AspNetCore to deserialize JSON content as an object. + /// AspNet provides this in System.Net.Http.Formatting. + /// + /// The content value. + public static async Task ReadAsObject(this HttpContent content) + { + string json = await content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(json); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Microsoft.AspNetCore.OData.Authorization.Tests.csproj b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Microsoft.AspNetCore.OData.Authorization.Tests.csproj new file mode 100644 index 0000000..2d3104b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Microsoft.AspNetCore.OData.Authorization.Tests.csproj @@ -0,0 +1,27 @@ + + + + netcoreapp3.1 + + false + + true + $(MSBuildThisFileDirectory)\..\..\tools\35MSSharedLib1024.snk + true + + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/PermissionsHelper.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/PermissionsHelper.cs new file mode 100644 index 0000000..4e7c402 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/PermissionsHelper.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Vocabularies; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Models +{ + public class PermissionsHelper + { + public static void AddPermissionsTo(EdmModel model, IEdmVocabularyAnnotatable target, string restrictionName, params string[] scopes) + { + model.AddVocabularyAnnotation(new EdmVocabularyAnnotation( + target, + model.FindTerm(restrictionName), + CreatePermission(scopes))); + } + + public static IEdmExpression CreatePermission(params string[] scopeNames) + { + var restriction = new EdmRecordExpression( + CreatePermissionProperty(scopeNames)); + + return restriction; + } + + public static IEdmPropertyConstructor CreatePermissionProperty(params string[] scopeNames) + { + var scopes = scopeNames.Select(scope => new EdmRecordExpression( + new EdmPropertyConstructor("Scope", new EdmStringConstant(scope)), + new EdmPropertyConstructor("RestrictedProperties", new EdmStringConstant("*")))); + + var permission = new EdmRecordExpression( + new EdmPropertyConstructor("SchemeName", new EdmStringConstant("AuthScheme")), + new EdmPropertyConstructor("Scopes", new EdmCollectionExpression(scopes))); + + var property = new EdmPropertyConstructor("Permissions", new EdmCollectionExpression(permission)); + return property; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/TestModel.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/TestModel.cs new file mode 100644 index 0000000..a7a9d05 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/Models/TestModel.cs @@ -0,0 +1,473 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNet.OData.Builder; +using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Vocabularies; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests.Models +{ + public class TestModel + { + public static IEdmModel GetModel() + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("RoutingCustomers"); + builder.EntitySet("Products"); + builder.EntitySet("SalesPeople"); + builder.EntitySet("EmailAddresses"); + builder.EntitySet<üCategory>("üCategories"); + builder.Singleton("VipCustomer"); + builder.Singleton("MyProduct"); + builder.EntitySet("DateTimeOffsetKeyCustomers"); + builder.EntitySet("Destinations"); + builder.EntitySet("Incidents"); + builder.EntitySet("IncidentGroups"); + builder.ComplexType(); + builder.ComplexType(); + builder.EntityType(); + builder.ComplexType(); + + ActionConfiguration getRoutingCustomerById = builder.Action("GetRoutingCustomerById"); + getRoutingCustomerById.Parameter("RoutingCustomerId"); + getRoutingCustomerById.ReturnsFromEntitySet("RoutingCustomers"); + + ActionConfiguration getSalesPersonById = builder.Action("GetSalesPersonById"); + getSalesPersonById.Parameter("salesPersonId"); + getSalesPersonById.ReturnsFromEntitySet("SalesPeople"); + + ActionConfiguration getAllVIPs = builder.Action("GetAllVIPs"); + ActionReturnsCollectionFromEntitySet(builder, getAllVIPs, "RoutingCustomers"); + + builder.EntityType().ComplexProperty
(c => c.Address); + builder.EntityType().Action("GetRelatedRoutingCustomers") + .ReturnsCollectionFromEntitySet("RoutingCustomers"); + + ActionConfiguration getBestRelatedRoutingCustomer = builder.EntityType() + .Action("GetBestRelatedRoutingCustomer"); + ActionReturnsFromEntitySet(builder, getBestRelatedRoutingCustomer, "RoutingCustomers"); + + ActionConfiguration getVIPS = builder.EntityType().Collection.Action("GetVIPs"); + ActionReturnsCollectionFromEntitySet(builder, getVIPS, "RoutingCustomers"); + + builder.EntityType().Collection.Action("GetProducts").ReturnsCollectionFromEntitySet("Products"); + builder.EntityType().Action("GetFavoriteProduct").ReturnsFromEntitySet("Products"); + builder.EntityType().Action("GetSalesPerson").ReturnsFromEntitySet("SalesPeople"); + builder.EntityType().Collection.Action("GetSalesPeople").ReturnsCollectionFromEntitySet("SalesPeople"); + + ActionConfiguration getMostProfitable = builder.EntityType().Collection.Action("GetMostProfitable"); + ActionReturnsFromEntitySet(builder, getMostProfitable, "RoutingCustomers"); + + ActionConfiguration getVIPRoutingCustomers = builder.EntityType().Action("GetVIPRoutingCustomers"); + ActionReturnsCollectionFromEntitySet(builder, getVIPRoutingCustomers, "RoutingCustomers"); + + ActionConfiguration getVIPRoutingCustomersOnCollection = builder.EntityType().Collection.Action("GetVIPRoutingCustomers"); + ActionReturnsCollectionFromEntitySet(builder, getVIPRoutingCustomersOnCollection, "RoutingCustomers"); + + builder.EntityType().HasRequired(v => v.RelationshipManager); + builder.EntityType().HasRequired(ip => ip.LeadSalesPerson); + + // function bound to an entity + FunctionConfiguration topProductId = builder.EntityType().Function("TopProductId"); + topProductId.Returns(); + + FunctionConfiguration topProductIdByCity = builder.EntityType().Function("TopProductIdByCity"); + topProductIdByCity.Parameter("city"); + topProductIdByCity.Returns(); + + FunctionConfiguration topProductIdByCityAndModel = builder.EntityType().Function("TopProductIdByCityAndModel"); + topProductIdByCityAndModel.Parameter("city"); + topProductIdByCityAndModel.Parameter("model"); + topProductIdByCityAndModel.Returns(); + + FunctionConfiguration optionFunctions = builder.EntityType().Collection.Function("GetCount").Returns(); + optionFunctions.Parameter("minSalary"); + optionFunctions.Parameter("maxSalary").Optional(); + optionFunctions.Parameter("aveSalary").Optional().HasDefaultValue("1200.99"); + + // function bound to a collection of entities + FunctionConfiguration topProductOfAll = builder.EntityType().Collection.Function("TopProductOfAll"); + topProductOfAll.Returns(); + + FunctionConfiguration topProductOfAllByCity = builder.EntityType().Collection.Function("TopProductOfAllByCity"); + topProductOfAllByCity.Parameter("city"); + topProductOfAllByCity.Returns(); + + FunctionConfiguration copyProductByCity = builder.EntityType().Function("CopyProductByCity"); + copyProductByCity.Parameter("city"); + copyProductByCity.Returns(); + + FunctionConfiguration topProductOfAllByCityAndModel = builder.EntityType().Collection.Function("TopProductOfAllByCityAndModel"); + topProductOfAllByCityAndModel.Parameter("city"); + topProductOfAllByCityAndModel.Parameter("model"); + topProductOfAllByCityAndModel.Returns(); + + // Function bound to the base entity type and derived entity type + builder.EntityType().Function("GetOrdersCount").Returns(); + builder.EntityType().Function("GetOrdersCount").Returns(); + + // Overloaded function only bound to the base entity type with one paramter + var getOrderCount = builder.EntityType().Function("GetOrdersCount"); + getOrderCount.Parameter("factor"); + getOrderCount.Returns(); + + // Function only bound to the derived entity type + builder.EntityType().Function("GetSpecialGuid").Returns(); + + // Function bound to the collection of the base and the derived entity type + builder.EntityType().Collection.Function("GetAllEmployees").Returns(); + builder.EntityType().Collection.Function("GetAllEmployees").Returns(); + + // Unbound function + builder.Function("UnboundFunction").ReturnsCollection().IsComposable = true; + + // Action only bound to the derived entity type + builder.EntityType().Action("ActionBoundToSpecialVIP"); + + // Action only bound to the derived entity type + builder.EntityType().Collection.Action("ActionBoundToSpecialVIPs"); + + // Function only bound to the base entity collection type + builder.EntityType().Collection.Function("FunctionBoundToRoutingCustomers").Returns(); + + // Function only bound to the derived entity collection type + builder.EntityType().Collection.Function("FunctionBoundToVIPs").Returns(); + + // Bound function with multiple parameters + var functionBoundToProductWithMultipleParamters = builder.EntityType().Function("FunctionBoundToProductWithMultipleParamters"); + functionBoundToProductWithMultipleParamters.Parameter("P1"); + functionBoundToProductWithMultipleParamters.Parameter("P2"); + functionBoundToProductWithMultipleParamters.Parameter("P3"); + functionBoundToProductWithMultipleParamters.Returns(); + + // Overloaded bound function with no parameter + builder.EntityType().Function("FunctionBoundToProduct").Returns(); + + // Overloaded bound function with one parameter + builder.EntityType().Function("FunctionBoundToProduct").Returns().Parameter("P1"); + + // Overloaded bound function with multiple parameters + var functionBoundToProduct = builder.EntityType().Function("FunctionBoundToProduct").Returns(); + functionBoundToProduct.Parameter("P1"); + functionBoundToProduct.Parameter("P2"); + functionBoundToProduct.Parameter("P3"); + + // Unbound function with one parameter + var unboundFunctionWithOneParamters = builder.Function("UnboundFunctionWithOneParamters"); + unboundFunctionWithOneParamters.Parameter("P1"); + unboundFunctionWithOneParamters.ReturnsFromEntitySet("RoutingCustomers"); + unboundFunctionWithOneParamters.IsComposable = true; + + // Unbound function with multiple parameters + var functionWithMultipleParamters = builder.Function("UnboundFunctionWithMultipleParamters"); + functionWithMultipleParamters.Parameter("P1"); + functionWithMultipleParamters.Parameter("P2"); + functionWithMultipleParamters.Parameter("P3"); + functionWithMultipleParamters.Returns(); + + // Overloaded unbound function with no parameter + builder.Function("OverloadUnboundFunction").Returns(); + + // Overloaded unbound function with one parameter + builder.Function("OverloadUnboundFunction").Returns().Parameter("P1"); + + // Overloaded unbound function with multiple parameters + var overloadUnboundFunction = builder.Function("OverloadUnboundFunction").Returns(); + overloadUnboundFunction.Parameter("P1"); + overloadUnboundFunction.Parameter("P2"); + overloadUnboundFunction.Parameter("P3"); + + var functionWithComplexTypeParameter = + builder.EntityType().Function("CanMoveToAddress").Returns(); + functionWithComplexTypeParameter.Parameter
("address"); + + var functionWithCollectionOfComplexTypeParameter = + builder.EntityType().Function("MoveToAddresses").Returns(); + functionWithCollectionOfComplexTypeParameter.CollectionParameter
("addresses"); + + var functionWithCollectionOfPrimitiveTypeParameter = + builder.EntityType().Function("CollectionOfPrimitiveTypeFunction").Returns(); + functionWithCollectionOfPrimitiveTypeParameter.CollectionParameter("intValues"); + + var functionWithEntityTypeParameter = + builder.EntityType().Function("EntityTypeFunction").Returns(); + functionWithEntityTypeParameter.EntityParameter("product"); + + var functionWithCollectionEntityTypeParameter = + builder.EntityType().Function("CollectionEntityTypeFunction").Returns(); + functionWithCollectionEntityTypeParameter.CollectionEntityParameter("products"); + + return builder.GetEdmModel(); + } + + public static IEdmModel GetModelWithPermissions() + { + var model = GetModel() as EdmModel; + AddPermissions(model); + return model; + } + + static void AddPermissions(EdmModel model) + { + var readRestrictions = "Org.OData.Capabilities.V1.ReadRestrictions"; + var insertRestrictions = "Org.OData.Capabilities.V1.InsertRestrictions"; + var updateRestrictions = "Org.OData.Capabilities.V1.UpdateRestrictions"; + var deleteRestrictions = "Org.OData.Capabilities.V1.DeleteRestrictions"; + var operationRestrictions = "Org.OData.Capabilities.V1.OperationRestrictions"; + var navigationRestrictions = "Org.OData.Capabilities.V1.NavigationRestrictions"; + + var product = model.FindDeclaredType("Microsoft.AspNet.OData.Test.Routing.Product") as IEdmEntityType; + var products = model.FindDeclaredEntitySet("Products"); + var myProduct = model.FindDeclaredSingleton("MyProduct"); + var customers = model.FindDeclaredEntitySet("RoutingCustomers"); + var vipCustomer = model.FindDeclaredSingleton("VipCustomer"); + var salesPeople = model.FindDeclaredEntitySet("SalesPeople"); + var incidentGroups = model.FindDeclaredEntitySet("IncidentGroups"); + var productFunction = model.SchemaElements.OfType() + .First(o => o.Name == "FunctionBoundToProduct" && o.Parameters.Count() == 1); + var productFunction2 = model.SchemaElements.OfType() + .First(o => o.Name == "FunctionBoundToProduct" && o.Parameters.Count() == 2); + var productFunction3 = model.SchemaElements.OfType() + .First(o => o.Name == "FunctionBoundToProduct" && o.Parameters.Count() == 4); + var topProduct = model.SchemaElements.OfType().First(o => o.Name == "TopProductOfAll"); + var getProducts = model.SchemaElements.OfType().First(o => o.Name == "GetProducts"); + var getFavoriteProduct = model.SchemaElements.OfType().First(o => o.Name == "GetFavoriteProduct"); + var getSalesPerson = model.SchemaElements.OfType().First(o => o.Name == "GetSalesPerson"); + var getSalesPeople = model.SchemaElements.OfType().First(o => o.Name == "GetSalesPeople"); + var getVIPRoutingCustomers = model.SchemaElements.OfType() + .First(o => o.Name == "GetVIPRoutingCustomers" && o.IsBound && !o.Parameters.First().Type.IsCollection()); + var getVIPRoutingCustomersBoundToCollection = model.SchemaElements.OfType() + .First(o => o.Name == "GetVIPRoutingCustomers" && o.IsBound && o.Parameters.First().Type.IsCollection()); + var getRoutingCustomerById = model.SchemaElements.OfType().First(o => o.Name == "GetRoutingCustomerById"); + var unboundFunction = model.SchemaElements.OfType().First(o => o.Name == "UnboundFunction"); + + model.AddVocabularyAnnotation(new EdmVocabularyAnnotation( + products, + model.FindTerm(readRestrictions), + new EdmRecordExpression( + PermissionsHelper.CreatePermissionProperty(new string[] { "Product.Read", "Product.ReadAll" }), + new EdmPropertyConstructor("ReadByKeyRestrictions", PermissionsHelper.CreatePermission(new[] { "Product.ReadByKey" }))))); + + + PermissionsHelper.AddPermissionsTo(model, products, insertRestrictions, "Product.Insert"); + PermissionsHelper.AddPermissionsTo(model, products, deleteRestrictions, "Product.Delete"); + PermissionsHelper.AddPermissionsTo(model, products, updateRestrictions, "Product.Update"); + + PermissionsHelper.AddPermissionsTo(model, myProduct, readRestrictions, "MyProduct.Read"); + PermissionsHelper.AddPermissionsTo(model, myProduct, deleteRestrictions, "MyProduct.Delete"); + PermissionsHelper.AddPermissionsTo(model, myProduct, updateRestrictions, "MyProduct.Update"); + + PermissionsHelper.AddPermissionsTo(model, incidentGroups, readRestrictions, "IncidentGroup.Read"); + + PermissionsHelper.AddPermissionsTo(model, productFunction, operationRestrictions, "Product.Function"); + PermissionsHelper.AddPermissionsTo(model, productFunction2, operationRestrictions, "Product.Function2"); + PermissionsHelper.AddPermissionsTo(model, productFunction3, operationRestrictions, "Product.Function3"); + PermissionsHelper.AddPermissionsTo(model, topProduct, operationRestrictions, "Product.Top"); + PermissionsHelper.AddPermissionsTo(model, getRoutingCustomerById, operationRestrictions, "GetRoutingCustomerById"); + PermissionsHelper.AddPermissionsTo(model, unboundFunction, operationRestrictions, "UnboundFunction"); + + model.AddVocabularyAnnotation(new EdmVocabularyAnnotation( + customers, + model.FindTerm(readRestrictions), + new EdmRecordExpression( + PermissionsHelper.CreatePermissionProperty(new string[] { "Customer.Read", "Customer.ReadAll" }), + new EdmPropertyConstructor("ReadByKeyRestrictions", PermissionsHelper.CreatePermission(new[] { "Customer.ReadByKey" }))))); + + PermissionsHelper.AddPermissionsTo(model, customers, insertRestrictions, "Customer.Insert"); + PermissionsHelper.AddPermissionsTo(model, customers, deleteRestrictions, "Customer.Delete"); + PermissionsHelper.AddPermissionsTo(model, customers, updateRestrictions, "Customer.Update"); + + model.AddVocabularyAnnotation(new EdmVocabularyAnnotation( + products, + model.FindTerm(navigationRestrictions), + new EdmRecordExpression( + new EdmPropertyConstructor("RestrictedProperties", new EdmCollectionExpression( + new EdmRecordExpression( + new EdmPropertyConstructor("NavigationProperty", new EdmNavigationPropertyPathExpression("Products/RoutingCustomers")), + new EdmPropertyConstructor("ReadRestrictions", new EdmRecordExpression( + PermissionsHelper.CreatePermissionProperty(new string[] { "ProductCustomers.Read" }), + new EdmPropertyConstructor("ReadByKeyRestrictions", PermissionsHelper.CreatePermission(new[] { "ProductCustomers.ReadByKey" })))), + new EdmPropertyConstructor("DeleteRestrictions", PermissionsHelper.CreatePermission("ProductCustomers.Delete")), + new EdmPropertyConstructor("UpdateRestrictions", PermissionsHelper.CreatePermission("ProductCustomers.Update")), + new EdmPropertyConstructor("InsertRestrictions", PermissionsHelper.CreatePermission("ProductCustomers.Insert")))))))); + + PermissionsHelper.AddPermissionsTo(model, vipCustomer, readRestrictions, "VipCustomer.Read"); + + model.AddVocabularyAnnotation(new EdmVocabularyAnnotation( + salesPeople, + model.FindTerm(readRestrictions), + new EdmRecordExpression( + PermissionsHelper.CreatePermissionProperty(new string[] { "SalesPerson.Read", "SalesPerson.ReadAll" }), + new EdmPropertyConstructor("ReadByKeyRestrictions", PermissionsHelper.CreatePermission(new[] { "SalesPerson.ReadByKey" }))))); + + PermissionsHelper.AddPermissionsTo(model, getProducts, operationRestrictions, "Customer.GetProducts"); + PermissionsHelper.AddPermissionsTo(model, getFavoriteProduct, operationRestrictions, "Customer.GetFavoriteProduct"); + PermissionsHelper.AddPermissionsTo(model, getSalesPerson, operationRestrictions, "Customer.GetSalesPerson"); + PermissionsHelper.AddPermissionsTo(model, getSalesPeople, operationRestrictions, "Customer.GetSalesPeople"); + PermissionsHelper.AddPermissionsTo(model, getVIPRoutingCustomers, operationRestrictions, "SalesPerson.GetVip"); + PermissionsHelper.AddPermissionsTo(model, + getVIPRoutingCustomersBoundToCollection, + operationRestrictions, + "SalesPerson.GetVipOnCollection"); + } + + public static ActionConfiguration ActionReturnsFromEntitySet(ODataModelBuilder builder, ActionConfiguration action, string entitySetName) where TEntityType : class + { + action.NavigationSource = CreateOrReuseEntitySet(builder, entitySetName); + action.ReturnType = builder.GetTypeConfigurationOrNull(typeof(TEntityType)); + return action; + } + + public static ActionConfiguration ActionReturnsCollectionFromEntitySet(ODataModelBuilder builder, ActionConfiguration action, string entitySetName) where TElementEntityType : class + { + Type clrCollectionType = typeof(IEnumerable); + action.NavigationSource = CreateOrReuseEntitySet(builder, entitySetName); + IEdmTypeConfiguration elementType = builder.GetTypeConfigurationOrNull(typeof(TElementEntityType)); + action.ReturnType = new CollectionTypeConfiguration(elementType, clrCollectionType); + return action; + } + + public static EntitySetConfiguration CreateOrReuseEntitySet(ODataModelBuilder builder, string entitySetName) where TElementEntityType : class + { + EntitySetConfiguration entitySet = builder.EntitySets.SingleOrDefault(s => s.Name == entitySetName); + + if (entitySet == null) + { + builder.EntitySet(entitySetName); + entitySet = builder.EntitySets.Single(s => s.Name == entitySetName); + } + else + { + builder.EntityType(); + } + return entitySet; + } + + public class RoutingCustomer + { + public int ID { get; set; } + public string Name { get; set; } + public virtual List Products { get; set; } + public Address Address { get; set; } + public Pet Pet { get; set; } + } + + public class EmailAddress + { + [Key] + public string Value { get; set; } + public string Text { get; set; } + } + + public class Address + { + public string Street { get; set; } + public string City { get; set; } + public string ZipCode { get; set; } + } + + public class UsAddress : Address + { + public string Country { get; set; } + } + + public class Pet + { + public string Name { get; set; } + public DateTimeOffset Birth { get; set; } + } + + public class Dog : Pet + { + public bool CanBark { get; set; } + public int RunSpeed { get; set; } + } + + public class Cat : Pet + { + public bool CanMeow { get; set; } + public int ClimbHeight { get; set; } + } + + public class Product + { + public int ID { get; set; } + public string Name { get; set; } + public List Tags { get; set; } + public virtual List RoutingCustomers { get; set; } + } + + public class SpecialProduct : Product + { + public int Value { get; set; } + } + + public class SalesPerson + { + public SalesPerson() + { + this.DynamicProperties = new Dictionary(); + } + + public int ID { get; set; } + public string Name { get; set; } + public virtual List ManagedRoutingCustomers { get; set; } + public virtual List ManagedProducts { get; set; } + public IDictionary DynamicProperties { get; set; } + } + + public class VIP : RoutingCustomer + { + public virtual SalesPerson RelationshipManager { get; set; } + public string Company { get; set; } + } + + public class SpecialVIP : VIP + { + public Guid SpecialGuid { get; set; } + } + + public class ImportantProduct : Product + { + public virtual SalesPerson LeadSalesPerson { get; set; } + } + + public class üCategory + { + public int ID { get; set; } + } + + public class DateTimeOffsetKeyCustomer + { + public DateTimeOffset ID { get; set; } + } + + public class Destination + { + public int Id { get; set; } + public string Name { get; set; } + public virtual List Parents { get; set; } + } + + public class DestinationGroup : Destination + { + public int GroupLocation { get; set; } + } + + public class Incident + { + public int ID { get; set; } + public string Name { get; set; } + } + + public class IncidentGroup + { + public int ID { get; set; } + public List Incidents { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationHandlerTest.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationHandlerTest.cs new file mode 100644 index 0000000..c1f31e4 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationHandlerTest.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OData.Authorization; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests +{ + public class ODataAuthorizationHandlerTest + { + [Theory] + [InlineData(new [] { "Calendar.Write" }, true)] + [InlineData(new [] { "User.Write", "User.Read" }, true)] + [InlineData(new [] { "Calendar.Read" }, false)] + [InlineData(new string[] { }, false)] + public void ShouldOnlySucceedIfUserHasAnAllowedScope(string[] userScopes, bool shouldSucceed) + { + var permissionData = new PermissionData() + { + Scopes = new List() + { + new PermissionScopeData() + { + Scope = "Calendar.Write" + }, + new PermissionScopeData() + { + Scope = "User.Write" + } + } + }; + var requirement = new ODataAuthorizationScopesRequirement(permissionData); + var context = CreateAuthContext("Permission", new[] { requirement }, userScopes); + var handler = new ODataAuthorizationHandler(FindScopes); + + handler.HandleAsync(context).Wait(); + + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + + + [Theory] + [InlineData(new[] { "User.Write", "User.Read" }, true)] + [InlineData(new[] { "Calendar.Read" }, false)] + public void ShouldGetScopesFromClaimsIfNoScopeFinderProvided(string[] userScopes, bool shouldSucceed) + { + var permissionData = new PermissionData() + { + Scopes = new List() + { + new PermissionScopeData() + { + Scope = "Calendar.Write" + }, + new PermissionScopeData() + { + Scope = "User.Write" + } + } + }; + + var requirement = new ODataAuthorizationScopesRequirement(permissionData); + var context = CreateAuthContext("Scope", new[] { requirement }, userScopes); + var handler = new ODataAuthorizationHandler(); + + handler.HandleAsync(context).Wait(); + + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + + private Task> FindScopes(ScopeFinderContext context) + { + var scopes = context.User.FindAll("Permission").Select(c => c.Value); + return Task.FromResult(scopes); + } + + + private AuthorizationHandlerContext CreateAuthContext(string scopeClaimKey, IEnumerable requirements, IEnumerable userScopes) + { + var identity = new System.Security.Principal.GenericIdentity("Me"); + foreach (var scope in userScopes) + { + identity.AddClaim(new System.Security.Claims.Claim(scopeClaimKey, scope)); + } + + var principal = new System.Security.Principal.GenericPrincipal(identity, Array.Empty()); + var context = new AuthorizationHandlerContext(requirements, principal, null); + return context; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationTest.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationTest.cs new file mode 100644 index 0000000..2514704 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataAuthorizationTest.cs @@ -0,0 +1,577 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Extensions; +using Microsoft.AspNet.OData.Routing; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Authorization.Tests.Abstractions; +using Microsoft.AspNetCore.OData.Authorization.Tests.Extensions; +using Microsoft.AspNetCore.OData.Authorization.Tests.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests +{ + public class ODataAuthorizationTest + { + private readonly HttpClient _client; + + public ODataAuthorizationTest() + { + var model = TestModel.GetModelWithPermissions(); + + var controllers = new[] + { + typeof(ProductsController), + typeof(MyProductController), + typeof(RoutingCustomersController), + typeof(VipCustomerController), + typeof(SalesPeopleController), + typeof(TodoItemController), + typeof(IncidentsController), + typeof(IncidentGroupsController) + }; + + var server = TestServerFactory.CreateWithEndpointRouting(controllers, endpoints => + { + endpoints.MapODataRoute("odata", "odata", model); + }, services => + { + services.AddODataAuthorization((options) => + { + options.ScopesFinder = (context) => + { + var permissions = context.User?.FindAll("Permission").Select(p => p.Value); + return Task.FromResult(permissions ?? Enumerable.Empty()); + }; + + options.ConfigureAuthentication("AuthScheme") + .AddScheme("AuthScheme", options => { }); + }); + + services.AddRouting(); + }, app => + { + app.UseAuthentication(); + app.UseODataAuthorization(); + }); + + _client = TestServerFactory.CreateClient(server); + } + + + [Theory] + // GET /entityset + [InlineData("GET", "Products", "Product.Read", "GET Products")] + [InlineData("GET", "Products", "Product.ReadAll", "GET Products")] + [InlineData("GET", "Products/$count", "Product.Read", "GET Products")] + [InlineData("GET", "Products/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Read", "GET SpecialProducts")] + [InlineData("GET", "Products/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/$count", "Product.Read", "GET SpecialProducts")] + // POST /entityset + [InlineData("POST", "Products", "Product.Insert", "POST Products")] + [InlineData("POST", "Products/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Insert", "POST SpecialProduct")] + // GET /entityset/key + [InlineData("GET", "Products(10)", "Product.ReadByKey", "GET Products(10)")] + [InlineData("GET", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.ReadByKey", "GET SpecialProduct(10)")] + // DELETE /entityset/key + [InlineData("DELETE", "Products(10)", "Product.Delete", "DELETE Products(10)")] + [InlineData("DELETE", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Delete", "DELETE SpecialProduct(10)")] + // PUT /entityset/key + [InlineData("PUT", "Products(10)", "Product.Update", "PUT Products(10)")] + [InlineData("PUT", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Update", "PUT SpecialProduct(10)")] + // PATCH /entityset/key + [InlineData("PATCH", "Products(10)", "Product.Update", "PATCH Products(10)")] + [InlineData("PATCH", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Update", "PATCH SpecialProduct(10)")] + [InlineData("MERGE", "Products(10)", "Product.Update", "PATCH Products(10)")] + [InlineData("MERGE", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "Product.Update", "PATCH SpecialProduct(10)")] + // /singleton and /singleton/cast + [InlineData("GET", "MyProduct", "MyProduct.Read", "GET MyProduct")] + [InlineData("GET", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "MyProduct.Read", "GET MySpecialProduct")] + [InlineData("PUT", "MyProduct", "MyProduct.Update", "PUT MyProduct")] + [InlineData("PUT", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "MyProduct.Update", "PUT MySpecialProduct")] + [InlineData("PATCH", "MyProduct", "MyProduct.Update", "PATCH MyProduct")] + [InlineData("PATCH", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "MyProduct.Update", "PATCH MySpecialProduct")] + [InlineData("MERGE", "MyProduct", "MyProduct.Update", "PATCH MyProduct")] + [InlineData("MERGE", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct", "MyProduct.Update", "PATCH MySpecialProduct")] + // bound functions + [InlineData("GET", "Products(10)/FunctionBoundToProduct", "Product.Function", "FunctionBoundToProduct(10)")] + [InlineData("GET", "Products(10)/FunctionBoundToProduct(P1=1)", "Product.Function2", "FunctionBoundToProduct(10, 1)")] + [InlineData("GET", "Products(10)/FunctionBoundToProduct(P1=1, P2=2, P3='3')", "Product.Function3", "FunctionBoundToProduct(10, 1, 2, 3)")] + [InlineData("GET", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/FunctionBoundToProduct", "Product.Function", "FunctionBoundToProduct(10)")] + // entityset functions + [InlineData("GET", "Products/TopProductOfAll", "Product.Top", "TopProductOfAll()")] + [InlineData("GET", "Products/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/TopProductOfAll", "Product.Top", "TopProductOfAll()")] + // singleton functions + [InlineData("GET", "MyProduct/FunctionBoundToProduct", "Product.Function", "FunctionBoundToProduct()")] + [InlineData("GET", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/FunctionBoundToProduct", "Product.Function", "FunctionBoundToProduct()")] + // entity actions + [InlineData("POST", "SalesPeople(10)/GetVIPRoutingCustomers", "SalesPerson.GetVip", "GetVIPRoutingCustomers(10)")] + [InlineData("POST", "SalesPeople/GetVIPRoutingCustomers", "SalesPerson.GetVipOnCollection", "GetVIPRoutingCustomers()")] + [InlineData("POST", "RoutingCustomers(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.VIP/GetSalesPerson", "Customer.GetSalesPerson", "GetSalesPersonOnVIP(10)")] + // entityset actions + [InlineData("POST", "RoutingCustomers/GetProducts", "Customer.GetProducts", "GetProducts()")] + [InlineData("POST", "RoutingCustomers/Microsoft.AspNetCore.OData.Authorization.Tests.Models.VIP/GetSalesPeople", "Customer.GetSalesPeople", "GetSalesPeopleOnVIP()")] + // singleton actions + [InlineData("POST", "VipCustomer/Microsoft.AspNetCore.OData.Authorization.Tests.Models.VIP/GetSalesPerson", "Customer.GetSalesPerson", "GetSalesPerson()")] + [InlineData("POST", "VipCustomer/GetFavoriteProduct", "Customer.GetFavoriteProduct", "GetFavoriteProduct()")] + // entityset/key/property + [InlineData("GET", "Products(10)/Name", "Product.ReadByKey", "GetProductName(10)")] + [InlineData("GET", "Products(10)/Name/$value", "Product.ReadByKey", "GetProductName(10)")] + [InlineData("GET", "Products(10)/Tags/$count", "Product.ReadByKey", "GetProductTags(10)")] + [InlineData("DELETE", "Products(10)/Name", "Product.Update", "DeleteProductName(10)")] + [InlineData("PATCH", "Products(10)/Name", "Product.Update", "PatchProductName(10)")] + [InlineData("PUT", "Products(10)/Name", "Product.Update", "PutProductName(10)")] + [InlineData("POST", "Products(10)/Tags", "Product.Update", "PostProductTags(10)")] + // entityset/key/cast/property + [InlineData("GET", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "Product.ReadByKey", "GetProductName(10)")] + [InlineData("GET", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name/$value", "Product.ReadByKey", "GetProductName(10)")] + [InlineData("GET", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Tags/$count", "Product.ReadByKey", "GetProductTags(10)")] + [InlineData("DELETE", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "Product.Update", "DeleteProductName(10)")] + [InlineData("PATCH", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "Product.Update", "PatchProductName(10)")] + [InlineData("PUT", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "Product.Update", "PutProductName(10)")] + [InlineData("POST", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Tags", "Product.Update", "PostProductTags(10)")] + // singleton/property + [InlineData("GET", "MyProduct/Name", "MyProduct.Read", "GetMyProductName")] + [InlineData("GET", "MyProduct/Name/$value", "MyProduct.Read", "GetMyProductName")] + [InlineData("GET", "MyProduct/Tags/$count", "MyProduct.Read", "GetMyProductTags")] + [InlineData("DELETE", "MyProduct/Name", "MyProduct.Update", "DeleteMyProductName")] + [InlineData("PATCH", "MyProduct/Name", "MyProduct.Update", "PatchMyProductName")] + [InlineData("PUT", "MyProduct/Name", "MyProduct.Update", "PutMyProductName")] + [InlineData("POST", "MyProduct/Tags", "MyProduct.Update", "PostMyProductTags")] + // singleton/cast/property + [InlineData("GET", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "MyProduct.Read", "GetMyProductName")] + [InlineData("GET", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name/$value", "MyProduct.Read", "GetMyProductName")] + [InlineData("GET", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Tags/$count", "MyProduct.Read", "GetMyProductTags")] + [InlineData("DELETE", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "MyProduct.Update", "DeleteMyProductName")] + [InlineData("PATCH", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "MyProduct.Update", "PatchMyProductName")] + [InlineData("PUT", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Name", "MyProduct.Update", "PutMyProductName")] + [InlineData("POST", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/Tags", "MyProduct.Update", "PostMyProductTags")] + // dynamic properties + [InlineData("GET", "SalesPeople(10)/SomeProperty", "SalesPerson.ReadByKey", "GetSalesPersonDynamicProperty(10, SomeProperty)")] + // navigation properties + [InlineData("GET", "Products(10)/RoutingCustomers", "Product.ReadByKey,Customer.Read", "GetProductCustomers(10)")] + [InlineData("POST", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/RoutingCustomers", "MyProduct.Update,Customer.Insert", "PostMyProductCustomer")] + // $ref + [InlineData("GET", "Products(10)/RoutingCustomers(20)/$ref", "Product.ReadByKey", "GetProductCustomerRef(10, 20)")] + [InlineData("POST", "MyProduct/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/RoutingCustomers/$ref", "MyProduct.Update", "CreateMyProductCustomerRef")] + [InlineData("DELETE", "Products(10)/Microsoft.AspNetCore.OData.Authorization.Tests.Models.SpecialProduct/RoutingCustomers(20)/$ref", "Product.Update", "DeleteProductCustomerRef(10, 20)")] + [InlineData("DELETE", "MyProduct/RoutingCustomers(20)/$ref", "MyProduct.Update", "DeleteMyProductCustomerRef(20)")] + [InlineData("PUT", "Products(10)/RoutingCustomers/$ref", "Product.Update", "CreateProductCustomerRef(10)")] + // unbound action + [InlineData("POST", "GetRoutingCustomerById", "GetRoutingCustomerById", "GetRoutingCustomerById")] + // unbound function + [InlineData("GET", "UnboundFunction", "UnboundFunction", "UnboundFunction")] + // complex routes requiring ODataRoute attribute + [InlineData("GET", "Products(10)/RoutingCustomers(20)/Address/Street", "Product.Read,Customer.ReadByKey", "GetProductRoutingCustomerAddressStreet")] + public async void ShouldApplyModelPermissionsToEndpoints(string method, string endpoint, string permissions, string expectedResponse) + { + var uri = $"http://localhost/odata/{endpoint}"; + // permission forbidden if auth not provided + HttpResponseMessage response = await _client.SendAsync(new HttpRequestMessage( + new HttpMethod(method), uri)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + // request succeeds if permission is correct + var message = new HttpRequestMessage(new HttpMethod(method), uri); + message.Headers.Add("Scopes", permissions); + + response = await _client.SendAsync(message); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedResponse, response.Content.AsObjectContentValue()); + } + + [Theory] + [InlineData("GET", "Incidents", "", "GetIncidents")] + [InlineData("GET", "IncidentGroups(10)/Incidents", "IncidentGroup.Read", "GetIncidentGroupIncidents(10)")] + public async void ShouldGrantAccessIfModelDoesNotDefinePermissions(string method, string endpoint, string permissions, string expectedResponse) + { + var uri = $"http://localhost/odata/{endpoint}"; + var message = new HttpRequestMessage(new HttpMethod(method), uri); + message.Headers.Add("Scopes", permissions); + + var response = await _client.SendAsync(message); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedResponse, response.Content.AsObjectContentValue()); + } + + [Fact] + public async void ShouldIgnoreNonODataEndpoints() + { + var uri = "http://localhost/api/TodoItems"; + HttpResponseMessage response = await _client.GetAsync(uri); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("GET TodoItems", response.Content.ReadAsStringAsync().Result); + + + var message = new HttpRequestMessage(new HttpMethod("GET"), uri); + message.Headers.Add("Scope", "Perm.Read"); + + response = await _client.SendAsync(message); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("GET TodoItems", response.Content.AsObjectContentValue()); + } + } + + internal class CustomAuthHandler : AuthenticationHandler + { + public CustomAuthHandler(IOptionsMonitor options, ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var identity = new System.Security.Principal.GenericIdentity("Me"); + var scopeValues = Request.Headers["Scopes"]; + if (scopeValues.Count != 0) + { + var scopes = scopeValues.ToArray()[0].Split(','); + identity.AddClaims(scopes.Select(scope => new Claim("Permission", scope))); + } + + var principal = new System.Security.Principal.GenericPrincipal(identity, Array.Empty()); + var ticket = new AuthenticationTicket(principal, "AuthScheme"); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + internal class CustomAuthOptions : AuthenticationSchemeOptions + { + } + + + [ApiController] + [Route("/api/TodoItems")] + public class TodoItemController: Controller + { + [HttpGet] + public string GetTodoItems() + { + return "GET TodoItems"; + } + } + + public class ProductsController : ODataController + { + public string Get() + { + return "GET Products"; + } + + public string GetProductsFromSpecialProduct() + { + return "GET SpecialProducts"; + } + + public string Post() + { + return "POST Products"; + } + + public string PostFromSpecialProduct() + { + return "POST SpecialProduct"; + } + + public string Get(int key) + { + return $"GET Products({key})"; + } + + public string GetSpecialProduct(int key) + { + return $"GET SpecialProduct({key})"; + } + + public string Delete(int key) + { + return $"DELETE Products({key})"; + } + + public string DeleteSpecialProduct(int key) + { + return $"DELETE SpecialProduct({key})"; + } + + public string Put(int key) + { + return $"PUT Products({key})"; + } + + public string PutSpecialProduct(int key) + { + return $"PUT SpecialProduct({key})"; + } + + public string Patch(int key) + { + return $"PATCH Products({key})"; + } + + public string PatchSpecialProduct(int key) + { + return $"PATCH SpecialProduct({key})"; + } + + public string FunctionBoundToProduct(int key) + { + return $"FunctionBoundToProduct({key})"; + } + + public string FunctionBoundToProduct(int key, int P1) + { + return $"FunctionBoundToProduct({key}, {P1})"; + } + + public string FunctionBoundToProduct(int key, int P1, int P2, string P3) + { + return $"FunctionBoundToProduct({key}, {P1}, {P2}, {P3})"; + } + + public string FunctionBoundToProductOnSpecialProduct(int key) + { + return $"FunctionBoundToSpecialProduct({key})"; + } + + public string TopProductOfAll() + { + return "TopProductOfAll()"; + } + + public string GetName(int key) + { + return $"GetProductName({key})"; + } + + public string PutToName(int key) + { + return $"PutProductName({key})"; + } + + public string PatchToName(int key) + { + return $"PatchProductName({key})"; + } + + public string DeleteToName(int key) + { + return $"DeleteProductName({key})"; + } + + public string PostToTags(int key) + { + return $"PostProductTags({key})"; + } + + public string GetTags(int key) + { + return $"GetProductTags({key})"; + } + + public string GetRoutingCustomers(int key) + { + return $"GetProductCustomers({key})"; + } + + [ODataRoute("Products({key})/RoutingCustomers({relatedKey})/$ref")] + public string GetRefToRoutingCustomers(int key, int relatedKey) + { + return $"GetProductCustomerRef({key}, {relatedKey})"; + } + + public string DeleteRefToRoutingCustomers(int key, int relatedKey) + { + return $"DeleteProductCustomerRef({key}, {relatedKey})"; + } + + public string CreateRefToRoutingCustomers(int key) + { + return $"CreateProductCustomerRef({key})"; + } + + [HttpGet] + [ODataRoute("Products({key})/RoutingCustomers({relatedKey})/Address/Street")] + public string GetProductRoutingCustomerAddressStreet(int key, int relatedKey) + { + return "GetProductRoutingCustomerAddressStreet"; + } + } + + public class MyProductController : ODataController + { + public string Get() + { + return "GET MyProduct"; + } + + public string GetFromSpecialProduct() + { + return "GET MySpecialProduct"; + } + + public string Put() + { + return "PUT MyProduct"; + } + + public string PutFromSpecialProduct() + { + return "PUT MySpecialProduct"; + } + + public string Patch() + { + return "PATCH MyProduct"; + } + + public string PatchFromSpecialProduct() + { + return "PATCH MySpecialProduct"; + } + + public string FunctionBoundToProduct() + { + return "FunctionBoundToProduct()"; + } + + public string GetName() + { + return "GetMyProductName"; + } + + public string PutToName() + { + return "PutMyProductName"; + } + + public string PatchToName() + { + return "PatchMyProductName"; + } + + public string DeleteToName() + { + return "DeleteMyProductName"; + } + + public string PostToTags() + { + return "PostMyProductTags"; + } + + public string GetTags() + { + return "GetMyProductTags"; + } + + public string PostToRoutingCustomers() + { + return "PostMyProductCustomer"; + } + + public string CreateRefToRoutingCustomers() + { + return $"CreateMyProductCustomerRef"; + } + + public string DeleteRefToRoutingCustomers(int relatedKey) + { + return $"DeleteMyProductCustomerRef({relatedKey})"; + } + } + + public class RoutingCustomersController : ODataController + { + public string GetProducts() + { + return "GetProducts()"; + } + public string GetSalesPersonOnVIP(int key) + { + return $"GetSalesPersonOnVIP({key})"; + } + + public string GetSalesPeopleOnCollectionOfVIP() + { + return "GetSalesPeopleOnVIP()"; + } + + [HttpPost] + [ODataRoute("GetRoutingCustomerById")] + public string GetRoutingCustomerById() + { + return "GetRoutingCustomerById"; + } + + [HttpGet] + [ODataRoute("UnboundFunction")] + public string UnboundFunction() + { + return "UnboundFunction"; + } + } + + public class VipCustomerController : ODataController + { + public string GetSalesPerson() + { + return "GetSalesPerson()"; + } + + public string GetFavoriteProduct() + { + return "GetFavoriteProduct()"; + } + + public string GetName() + { + return "GetName()"; + } + } + + public class SalesPeopleController : ODataController + { + public string GetVIPRoutingCustomers(int key) + { + return $"GetVIPRoutingCustomers({key})"; + } + + public string GetVIPRoutingCustomers() + { + return "GetVIPRoutingCustomers()"; + } + + public string GetDynamicProperty(int key, string dynamicProperty) + { + return $"GetSalesPersonDynamicProperty({key}, {dynamicProperty})"; + } + } + + public class IncidentsController : ODataController + { + public string Get() + { + return "GetIncidents"; + } + } + + public class IncidentGroupsController : ODataController + { + [ODataRoute("IncidentGroups({key})/Incidents")] + public string GetIncidents(int key) + { + return $"GetIncidentGroupIncidents({key})"; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataModelPermissionExtractorTest.cs b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataModelPermissionExtractorTest.cs new file mode 100644 index 0000000..2ed82b9 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Authorization.Tests/ODataModelPermissionExtractorTest.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNet.OData; +using Microsoft.AspNet.OData.Routing; +using Microsoft.AspNetCore.OData.Authorization.Tests.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using System; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Authorization.Tests +{ + public class ODataModelPermissionExtractorTest + { + + IEdmModel _model = TestModel.GetModelWithPermissions(); + string _serviceRoot = "http://odata/"; + DefaultODataPathHandler _parser = new DefaultODataPathHandler(); + IServiceProvider _serviceProvider; + + public ODataModelPermissionExtractorTest() + { + _serviceProvider = CreateServiceProvider(_model); + } + + private static IServiceProvider CreateServiceProvider(IEdmModel model) + { + var container = new ServiceCollection(); + container.AddSingleton(model); + container.AddSingleton(); + container.AddSingleton(); + container.AddSingleton(); + container.AddSingleton(); + container.AddSingleton(); + return container.BuildServiceProvider(); + } + + [Theory] + // Entity set CRUD + [InlineData("GET", "Products", "Product.ReadAll")] + [InlineData("GET", "Products(1)", "Product.ReadAll")] + [InlineData("GET", "Products(1)", "Product.ReadByKey")] + [InlineData("PUT", "Products(1)", "Product.Update")] + [InlineData("PATCH", "Products(1)", "Product.Update")] + [InlineData("MERGE", "Products(1)", "Product.Update")] + [InlineData("DELETE", "Products(1)", "Product.Delete")] + [InlineData("POST", "Products", "Product.Insert")] + // Singleton CRUD + [InlineData("GET", "MyProduct", "MyProduct.Read")] + [InlineData("PUT", "MyProduct", "MyProduct.Update")] + [InlineData("PATCH", "MyProduct", "MyProduct.Update")] + [InlineData("MERGE", "MyProduct", "MyProduct.Update")] + [InlineData("DELETE", "MyProduct", "MyProduct.Delete")] + // Property access + [InlineData("GET", "Products(10)/Name", "Product.ReadByKey")] + [InlineData("GET", "Products(10)/Name/$value", "Product.ReadByKey")] + [InlineData("GET", "Products(10)/Tags/$count", "Product.ReadByKey")] + [InlineData("DELETE", "Products(10)/Name", "Product.Update")] + [InlineData("PATCH", "Products(10)/Name", "Product.Update")] + [InlineData("PUT", "Products(10)/Name", "Product.Update")] + [InlineData("POST", "Products(10)/Tags", "Product.Update")] + [InlineData("GET", "MyProduct/Name", "MyProduct.Read")] + [InlineData("PUT", "MyProduct/Name", "MyProduct.Update")] + [InlineData("PATCH", "MyProduct/Name", "MyProduct.Update")] + [InlineData("DELETE", "MyProduct/Name", "MyProduct.Update")] + // Navigation Properties + [InlineData("GET", "Products(10)/RoutingCustomers", "Product.ReadByKey,Customer.Read")] + [InlineData("GET", "Products(10)/RoutingCustomers", "Product.Read,ProductCustomers.Read")] + [InlineData("GET", "Products(10)/RoutingCustomers(10)", "Product.Read,ProductCustomers.Read")] + [InlineData("GET", "Products(10)/RoutingCustomers(10)", "Product.Read,ProductCustomers.ReadByKey")] + [InlineData("GET", "Products(10)/RoutingCustomers(10)", "Product.ReadByKey,Customer.ReadByKey")] + [InlineData("POST", "Products(10)/RoutingCustomers", "Product.Update,Customer.Insert")] + [InlineData("POST", "Products(10)/RoutingCustomers", "Product.Update,ProductCustomers.Insert")] + [InlineData("PUT", "Products(10)/RoutingCustomers(10)", "Product.Update,Customer.Update")] + [InlineData("PUT", "Products(10)/RoutingCustomers(10)", "Product.Update,ProductCustomers.Update")] + [InlineData("DELETE", "Products(10)/RoutingCustomers(10)", "Product.Update,Customer.Delete")] + [InlineData("DELETE", "Products(10)/RoutingCustomers(10)", "Product.Update,ProductCustomers.Delete")] + public void PermissionEvaluator_ReturnsTrue_IfScopesMatchRequiredPermissions(string method, string endpoint, string userScopes) + { + var path = _parser.Parse(_serviceRoot, endpoint, _serviceProvider); + var scopesList = userScopes.Split(','); + + var permissionHandler = _model.ExtractPermissionsForRequest(method, path); + + Assert.True(permissionHandler.AllowsScopes(scopesList)); + } + + [Theory] + [InlineData("GET", "Products", "")] + [InlineData("GET", "Products", "Customers.Read")] + [InlineData("GET", "Products(10)/RoutingCustomers", "Product.ReadByKey")] + [InlineData("GET", "Products(10)/RoutingCustomers", "ProductCustomers.Read")] + public void PermissionEvaluator_ReturnsFalse_IfRequiredScopesNotFound(string method, string endpoint, string userScopes) + { + var path = _parser.Parse(_serviceRoot, endpoint, _serviceProvider); + var scopesList = userScopes.Split(','); + + var permissionHandler = _model.ExtractPermissionsForRequest(method, path); + + Assert.False(permissionHandler.AllowsScopes(scopesList)); + } + } +} diff --git a/tools/35MSSharedLib1024.snk b/tools/35MSSharedLib1024.snk new file mode 100644 index 0000000..695f1b3 Binary files /dev/null and b/tools/35MSSharedLib1024.snk differ