Skip to content

Commit

Permalink
Modified authorization view incorporation to use CTEs instead of view…
Browse files Browse the repository at this point in the history
…s with DISTINCT pushed up to the view application, with support for opting in to CTE-based authorization through the useCteAuth query string parameter.
  • Loading branch information
gmcelhanon committed Oct 17, 2024
1 parent 23f34a6 commit 84141f1
Show file tree
Hide file tree
Showing 8 changed files with 359 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ public abstract class DataManagementControllerBase<TResourceModel, TEntityInterf
protected Lazy<GetManyPipeline<TResourceModel, TAggregateRoot>> GetManyPipeline;
protected Lazy<PutPipeline<TResourceModel, TAggregateRoot>> PutPipeline;

private static readonly IContextStorage _contextStorage = new CallContextStorage();

protected DataManagementControllerBase(
IPipelineFactory pipelineFactory,
IEdFiProblemDetailsProvider problemDetailsProvider,
Expand Down Expand Up @@ -148,7 +150,13 @@ public virtual async Task<IActionResult> GetAll(
[FromQuery] Dictionary<string, string> additionalParameters = default)
{
//respond quickly to DOS style requests (should we catch these earlier? e.g. attribute filter?)


// Store alternative auth approach decision into call context
if (additionalParameters?.TryGetValue("useCteAuth", out string useCteAuth) == true)
{
_contextStorage.SetValue("UseCteAuth", Convert.ToBoolean(useCteAuth));
}

var queryParameters = new QueryParameters(urlQueryParametersRequest);

if (!QueryParametersValidator.IsValid(queryParameters, _defaultPageLimitSize, out string errorMessage))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public class PartitionsController : ControllerBase
private readonly IOdsDatabaseConnectionStringProvider _odsDatabaseConnectionStringProvider;
private readonly IPartitionsQueryBuilderProvider _partitionsQueryBuilderProvider;

private static readonly IContextStorage _contextStorage = new CallContextStorage();

public PartitionsController(
DbProviderFactory dbProviderFactory,
IContextProvider<DataManagementResourceContext> dataManagementResourceContextProvider,
Expand All @@ -67,6 +69,12 @@ public async Task<IActionResult> Get(
[FromQuery] int number = 1,
[FromQuery] Dictionary<string, string> additionalParameters = default)
{
// Store alternative auth approach decision into call context
if (additionalParameters?.TryGetValue("useCteAuth", out string useCteAuth) == true)
{
_contextStorage.SetValue("UseCteAuth", Convert.ToBoolean(useCteAuth));
}

if (number is < 1 or > 200)
{
var problemDetails = new BadRequestParameterException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ public QueryBuilder GetQueryBuilder(
queryParameters,
additionalQueryParameters);

// Do not process if CTE auth has been indicated
bool shouldUseCteAuth = additionalQueryParameters?.TryGetValue("UseCteAuth", out string useCteAuth) == true
&& Convert.ToBoolean(useCteAuth);

if (shouldUseCteAuth)
{
return queryBuilder;
}

var authorizationFiltering = _authorizationFilterContextProvider.GetFilterContext();

var unsupportedAuthorizationFilters = new HashSet<string>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

using System;
using System.Collections.Generic;
using System.Linq;
using EdFi.Ods.Common;
using EdFi.Ods.Api.Security.Authorization.Filtering;
using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters;
using EdFi.Ods.Common.Database.Querying;
using EdFi.Ods.Common.Infrastructure.Filtering;
using EdFi.Ods.Common.Models.Domain;
using EdFi.Ods.Common.Providers.Queries;
using EdFi.Ods.Common.Security.Authorization;

namespace EdFi.Ods.Api.Security.Authorization.Repositories
{
/// <summary>
/// Provides an abstract implementation for applying authorization filters to queries on aggregate roots built using the <see cref="QueryBuilder"/>.
/// </summary>
public class AggregateRootQueryBuilderProviderCteAuthorizationDecorator : IAggregateRootQueryBuilderProvider
{
private readonly IAggregateRootQueryBuilderProvider _decoratedInstance;
private readonly IAuthorizationFilterContextProvider _authorizationFilterContextProvider;
private readonly IAuthorizationFilterDefinitionProvider _authorizationFilterDefinitionProvider;

public AggregateRootQueryBuilderProviderCteAuthorizationDecorator(
IAggregateRootQueryBuilderProvider decoratedInstance,
IAuthorizationFilterContextProvider authorizationFilterContextProvider,
IAuthorizationFilterDefinitionProvider authorizationFilterDefinitionProvider)
{
_decoratedInstance = decoratedInstance;
_authorizationFilterContextProvider = authorizationFilterContextProvider;
_authorizationFilterDefinitionProvider = authorizationFilterDefinitionProvider;
}

/// <summary>
/// Applies the authorization filtering criteria to the query created by the decorated instance.
/// </summary>
/// <param name="aggregateRootEntity"></param>
/// <param name="specification">An instance of the entity representing the parameters to the query.</param>
/// <param name="queryParameters">The parameter values to apply to the query.</param>
/// <param name="additionalQueryParameters">Additional parameters supplied by the API client that are resource-level properties or common parameters.</param>
/// <returns>The criteria created by the decorated instance.</returns>
public QueryBuilder GetQueryBuilder(
Entity aggregateRootEntity,
AggregateRootWithCompositeKey specification,
IQueryParameters queryParameters,
IDictionary<string, string> additionalQueryParameters)
{
var queryBuilder = _decoratedInstance.GetQueryBuilder(
aggregateRootEntity,
specification,
queryParameters,
additionalQueryParameters);

// Only process if CTE auth has been indicated
bool shouldUseCteAuth = additionalQueryParameters?.TryGetValue("UseCteAuth", out string useCteAuth) == true
&& Convert.ToBoolean(useCteAuth);

if (!shouldUseCteAuth)
{
return queryBuilder;
}

var authorizationFiltering = _authorizationFilterContextProvider.GetFilterContext();

var unsupportedAuthorizationFilters = new HashSet<string>();

// If there are multiple relationship-based authorization strategies with views (that are combined with OR), we must use left outer joins and null/not null checks
var relationshipBasedAuthViewJoinType = DetermineRelationshipBasedAuthViewJoinType();

ApplyAuthorizationStrategiesCombinedWithAndLogic();
ApplyAuthorizationStrategiesCombinedWithOrLogic();

return queryBuilder;

JoinType? DetermineRelationshipBasedAuthViewJoinType()
{
// NOTE: Relationship-based authorization filters are combined using OR, while custom auth-view filters are combined using AND
var countOfRelationshipBasedAuthorizationFilters = authorizationFiltering.Count(
af => af.Operator == FilterOperator.Or && af.Filters.Select(afd =>
{
if (_authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition(afd.FilterName, out var filterDetails))
{
return filterDetails;
};

unsupportedAuthorizationFilters.Add(afd.FilterName);

return null;
})
.Where(x => x != null)
.OfType<ViewBasedAuthorizationFilterDefinition>()
.Any());

return countOfRelationshipBasedAuthorizationFilters switch
{
0 => null,
1 => JoinType.InnerJoin,
_ => JoinType.LeftOuterJoin
};
}

void ApplyAuthorizationStrategiesCombinedWithAndLogic()
{
var andStrategies = authorizationFiltering.Where(x => x.Operator == FilterOperator.And).ToArray();

// Combine 'AND' strategies
foreach (var andStrategy in andStrategies)
{
if (!TryApplyFilters(queryBuilder, andStrategy.Filters, andStrategy.AuthorizationStrategy, JoinType.InnerJoin))
{
// All filters for AND strategies must be applied, and if not, this is an error condition
throw new Exception($"The following authorization filters are not recognized: {string.Join(" ", unsupportedAuthorizationFilters)}");
}
}
}

void ApplyAuthorizationStrategiesCombinedWithOrLogic()
{
var orStrategies = authorizationFiltering
.Where(x => x.Operator == FilterOperator.Or)
.ToArray();

bool disjunctionFiltersApplied = false;

// Combine 'OR' strategies
queryBuilder.Where(
disjunctionBuilder =>
{
foreach (var orStrategy in orStrategies)
{
disjunctionBuilder.OrWhere(
filtersConjunctionBuilder =>
{
// Combine filters with 'AND'
if (TryApplyFilters(
filtersConjunctionBuilder,
orStrategy.Filters,
orStrategy.AuthorizationStrategy,
relationshipBasedAuthViewJoinType ?? JoinType.InnerJoin))
{
disjunctionFiltersApplied = true;
}

return filtersConjunctionBuilder;
});
}

return disjunctionBuilder;
});

// If we have some OR strategies with filters defined, but no filters were applied, this is an error condition
if (orStrategies.SelectMany(s => s.Filters).Any() && !disjunctionFiltersApplied)
{
throw new Exception($"The following authorization filters are not recognized: {string.Join(" ", unsupportedAuthorizationFilters)}");
}
}

bool TryApplyFilters(
QueryBuilder conjunctionQueryBuilder,
IReadOnlyList<AuthorizationFilterContext> filters,
IAuthorizationStrategy authorizationStrategy,
JoinType joinType)
{
bool allFiltersCanBeApplied = true;

foreach (var filterDetails in filters)
{
if (!_authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition(
filterDetails.FilterName,
out var ignored))
{
unsupportedAuthorizationFilters.Add(filterDetails.FilterName);

allFiltersCanBeApplied = false;
}
}

if (!allFiltersCanBeApplied)
{
return false;
}

bool filtersApplied = false;

foreach (var filterContext in filters)
{
_authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition(
filterContext.FilterName,
out var filterDefinition);

var parameterValues = filterContext.ClaimParameterName == null
? new Dictionary<string, object>()
: new Dictionary<string, object>
{
{ filterContext.ClaimParameterName, filterContext.ClaimParameterValues }
};

// Apply the authorization strategy filter
filterDefinition.CriteriaApplicator(
queryBuilder,
conjunctionQueryBuilder,
filterContext.SubjectEndpointNames,
parameterValues,
joinType,
authorizationStrategy);

filtersApplied = true;
}

return filtersApplied;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using EdFi.Ods.Common;
using EdFi.Ods.Common.Context;
using EdFi.Ods.Common.Database.Querying;
using EdFi.Ods.Common.Security.Authorization;
using EdFi.Ods.Common.Security.CustomViewBased;
Expand All @@ -15,6 +15,8 @@ namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters
{
public static class QueryBuilderExtensions
{
private static readonly CallContextStorage _callContextStorage = new();

/// <summary>
/// Applies a join-based filter to the criteria for the specified authorization view.
/// </summary>
Expand All @@ -36,6 +38,19 @@ public static void ApplySingleColumnJoinFilter(
JoinType joinType,
string authViewAlias = null)
{
// Temporary logic to opt-in to CTE-based authorization approach
if (_callContextStorage.GetValue<bool>("UseCteAuth"))
{
ApplySingleColumnJoinFilterUsingCtes(queryBuilder, parameters, viewName, subjectEndpointName, viewSourceEndpointName, viewTargetEndpointName, joinType, authViewAlias);
return;
}

// Defensive check to ensure required parameter is present
if (!parameters.TryGetValue(RelationshipAuthorizationConventions.ClaimsParameterName, out object value))
{
throw new Exception($"Unable to find parameter for filtering '{RelationshipAuthorizationConventions.ClaimsParameterName}' on view '{viewName}'. Available parameters: '{string.Join("', '", parameters.Keys)}'");
}

authViewAlias = string.IsNullOrWhiteSpace(authViewAlias) ? $"authView{viewName}" : $"authView{authViewAlias}";

// Apply authorization join using ICriteria
Expand All @@ -56,12 +71,6 @@ public static void ApplySingleColumnJoinFilter(
throw new NotSupportedException("Unsupported authorization view join type.");
}

// Defensive check to ensure required parameter is present
if (!parameters.TryGetValue(RelationshipAuthorizationConventions.ClaimsParameterName, out object value))
{
throw new Exception($"Unable to find parameter for filtering '{RelationshipAuthorizationConventions.ClaimsParameterName}' on view '{viewName}'. Available parameters: '{string.Join("', '", parameters.Keys)}'");
}

if (value is object[] arrayOfValues)
{
if (joinType == JoinType.InnerJoin)
Expand Down Expand Up @@ -90,9 +99,62 @@ public static void ApplySingleColumnJoinFilter(
}
}

private static string GetFullNameForView(this string viewName)
private static void ApplySingleColumnJoinFilterUsingCtes(
this QueryBuilder queryBuilder,
IDictionary<string, object> parameters,
string viewName,
string subjectEndpointName,
string viewSourceEndpointName,
string viewTargetEndpointName,
JoinType joinType,
string authViewAlias = null)
{
return Namespaces.Entities.NHibernate.QueryModels.GetViewNamespace(viewName);
// Defensive check to ensure required parameter is present
if (!parameters.TryGetValue(RelationshipAuthorizationConventions.ClaimsParameterName, out object value))
{
throw new Exception($"Unable to find parameter for filtering '{RelationshipAuthorizationConventions.ClaimsParameterName}' on view '{viewName}'. Available parameters: '{string.Join("', '", parameters.Keys)}'");
}

authViewAlias = string.IsNullOrWhiteSpace(authViewAlias) ? $"authView{viewName}" : $"authView{authViewAlias}";

// Create a CTE query for the authorization view
var cte = new QueryBuilder(queryBuilder.Dialect);
cte.From($"auth.{viewName} AS av");
cte.Select($"av.{viewTargetEndpointName}");
cte.Distinct();

// Apply claims to the CTE query
if (value is object[] arrayOfValues)
{
cte.WhereIn($"av.{viewSourceEndpointName}", arrayOfValues);
}
else
{
cte.Where($"av.{viewSourceEndpointName}", value);
}

// Add the CTE to the main query, with alias
queryBuilder.With(authViewAlias, cte);

// Apply join to the authorization CTE
if (joinType == JoinType.InnerJoin)
{
queryBuilder.Join(
authViewAlias,
j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}"));
}
else if (joinType == JoinType.LeftOuterJoin)
{
queryBuilder.LeftJoin(
authViewAlias,
j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}"));

queryBuilder.Where(qb => qb.WhereNotNull($"{authViewAlias}.{viewTargetEndpointName}"));
}
else
{
throw new NotSupportedException("Unsupported authorization view join type.");
}
}

/// <summary>
Expand Down
Loading

0 comments on commit 84141f1

Please sign in to comment.