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