From 84141f15ab34821513eb54f2d2a06a6190133114 Mon Sep 17 00:00:00 2001 From: Geoffrey McElhanon Date: Wed, 16 Oct 2024 23:44:38 -0500 Subject: [PATCH] Modified authorization view incorporation to use CTEs instead of views with DISTINCT pushed up to the view application, with support for opting in to CTE-based authorization through the useCteAuth query string parameter. --- .../DataManagementControllerBase.cs | 10 +- .../Controllers/PartitionsController.cs | 8 + ...ryBuilderProviderAuthorizationDecorator.cs | 9 + ...uilderProviderCteAuthorizationDecorator.cs | 219 ++++++++++++++++++ .../Filters/QueryBuilderExtensions.cs | 80 ++++++- .../Modules/SecurityPersistenceModule.cs | 3 +- .../Database/Querying/QueryBuilder.cs | 49 +++- .../Querying/SqlBuilder_customizations.cs | 1 + 8 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs diff --git a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs index fd8faf7c38..458a4ef633 100644 --- a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs +++ b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs @@ -72,6 +72,8 @@ public abstract class DataManagementControllerBase> GetManyPipeline; protected Lazy> PutPipeline; + private static readonly IContextStorage _contextStorage = new CallContextStorage(); + protected DataManagementControllerBase( IPipelineFactory pipelineFactory, IEdFiProblemDetailsProvider problemDetailsProvider, @@ -148,7 +150,13 @@ public virtual async Task GetAll( [FromQuery] Dictionary 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)) diff --git a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs index a58a223d31..27b84844be 100644 --- a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs +++ b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs @@ -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 dataManagementResourceContextProvider, @@ -67,6 +69,12 @@ public async Task Get( [FromQuery] int number = 1, [FromQuery] Dictionary 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( diff --git a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs index ee16863d84..24b492f69c 100644 --- a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs +++ b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs @@ -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(); diff --git a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs new file mode 100644 index 0000000000..7a0cfe5315 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs @@ -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 +{ + /// + /// Provides an abstract implementation for applying authorization filters to queries on aggregate roots built using the . + /// + 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; + } + + /// + /// Applies the authorization filtering criteria to the query created by the decorated instance. + /// + /// + /// An instance of the entity representing the parameters to the query. + /// The parameter values to apply to the query. + /// Additional parameters supplied by the API client that are resource-level properties or common parameters. + /// The criteria created by the decorated instance. + public QueryBuilder GetQueryBuilder( + Entity aggregateRootEntity, + AggregateRootWithCompositeKey specification, + IQueryParameters queryParameters, + IDictionary 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(); + + // 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() + .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 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() + : new Dictionary + { + { filterContext.ClaimParameterName, filterContext.ClaimParameterValues } + }; + + // Apply the authorization strategy filter + filterDefinition.CriteriaApplicator( + queryBuilder, + conjunctionQueryBuilder, + filterContext.SubjectEndpointNames, + parameterValues, + joinType, + authorizationStrategy); + + filtersApplied = true; + } + + return filtersApplied; + } + } + } +} diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs index d055bee994..7b4b756956 100644 --- a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs @@ -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; @@ -15,6 +15,8 @@ namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters { public static class QueryBuilderExtensions { + private static readonly CallContextStorage _callContextStorage = new(); + /// /// Applies a join-based filter to the criteria for the specified authorization view. /// @@ -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("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 @@ -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) @@ -90,9 +99,62 @@ public static void ApplySingleColumnJoinFilter( } } - private static string GetFullNameForView(this string viewName) + private static void ApplySingleColumnJoinFilterUsingCtes( + this QueryBuilder queryBuilder, + IDictionary 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."); + } } /// diff --git a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs index 84e16ec9b4..6e05e54f1f 100644 --- a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs +++ b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: Apache-2.0 +// 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. @@ -50,6 +50,7 @@ public class SecurityPersistenceModule : Module {typeof(AuthorizationContextDeletePipelineStepsProviderDecorator), typeof(IDeletePipelineStepsProvider)}, {typeof(AggregateRootQueryBuilderProviderAuthorizationDecorator), typeof(IAggregateRootQueryBuilderProvider)}, + {typeof(AggregateRootQueryBuilderProviderCteAuthorizationDecorator), typeof(IAggregateRootQueryBuilderProvider)}, }; protected override void Load(ContainerBuilder builder) diff --git a/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs b/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs index 3d0d88a457..e85bf1e594 100644 --- a/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs +++ b/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs @@ -11,6 +11,7 @@ using Dapper; using EdFi.Common.Utils.Extensions; using EdFi.Ods.Common.Database.Querying.Dialects; +using NHibernate.SqlCommand; namespace EdFi.Ods.Common.Database.Querying { @@ -52,6 +53,11 @@ private QueryBuilder(Dialect dialect, SqlBuilder sqlBuilder, string tableName, P TableName = tableName; } + public Dialect Dialect + { + get => _dialect; + } + public string TableName { get; private set; } public IDictionary Parameters { get; } = new Dictionary(); @@ -156,7 +162,7 @@ public QueryBuilder Where(Func nestedWhereApplicator } // Incorporate any JOINs added into this builder - _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "innerjoin", "leftjoin", "rightjoin", "join"); + _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "with", "innerjoin", "leftjoin", "rightjoin", "join"); return this; } @@ -176,17 +182,27 @@ public QueryBuilder OrWhere(Func nestedWhereApplicat return this; } - var template = childScopeSqlBuilder.AddTemplate( - "/**where**/", - childScope.Parameters.Any() - ? new DynamicParameters(childScope.Parameters) - : null); + if (childScopeSqlBuilder.HasWhereClause()) + { + var template = childScopeSqlBuilder.AddTemplate( + "/**where**/", + childScope.Parameters.Any() + ? new DynamicParameters(childScope.Parameters) + : null); - // SqlBuilder warps 'OR' where clauses when building the template SQL - _sqlBuilder.OrWhere($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters); + // SqlBuilder wraps 'OR' where clauses when building the template SQL + _sqlBuilder.OrWhere($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters); + } + else + { + if (childScope.Parameters.Any()) + { + _sqlBuilder.AddParameters(childScope.Parameters); + } + } // Incorporate the JOINs into this builder - _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "innerjoin", "leftjoin", "rightjoin", "join"); + _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "with", "innerjoin", "leftjoin", "rightjoin", "join"); return this; } @@ -496,6 +512,21 @@ public void ClearSelect() _sqlBuilder.ClearClause(ClauseKey.Select); _sqlBuilder.ClearClause(ClauseKey.Distinct); } + + /// + /// Clears the CTE queries (e.g. for building a COUNT query with a cloned QueryBuilder, since they will already be present on the final query). + /// + public void ClearWith() + { + _sqlBuilder.ClearClause(ClauseKey.With); + } + + public QueryBuilder AddParameters(DynamicParameters parameters) + { + _sqlBuilder.AddParameters(parameters); + + return this; + } } public class ParameterIndexer diff --git a/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs b/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs index b0522c79ea..4b1fbe50bb 100644 --- a/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs +++ b/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs @@ -190,5 +190,6 @@ public static class ClauseKey public static string Having = "having"; public static string Set = "set"; public static string Distinct = "distinct"; + public static string With = "with"; } }