Skip to content

Commit

Permalink
[ODS-6427] Support extensibility for change queries request authoriza…
Browse files Browse the repository at this point in the history
…tion based on custom database views (#1120)
  • Loading branch information
gmcelhanon authored Aug 16, 2024
1 parent d1a10d0 commit 579545e
Show file tree
Hide file tree
Showing 7 changed files with 1,545 additions and 597 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,54 +4,40 @@
// See the LICENSE and NOTICES files in the project root for more information.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using EdFi.Common.Extensions;
using EdFi.Ods.Api.Security.Authorization;
using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters;
using EdFi.Ods.Api.Security.Extensions;
using EdFi.Ods.Common.Database.NamingConventions;
using EdFi.Ods.Common.Database.Querying;
using EdFi.Ods.Common.Exceptions;
using EdFi.Ods.Common.Infrastructure.Filtering;
using EdFi.Ods.Common.Models.Resource;
using EdFi.Ods.Common.Security;
using EdFi.Ods.Common.Security.Authorization;
using EdFi.Ods.Common.Security.Claims;
using NHibernate;
using NHibernate.Criterion;
using NHibernate.SqlCommand;

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.CustomViewBased;

// ------------------------------------------------------------------------------------------
// TODO: ODS-6426, ODS-6427 - This file is a work-in-progress across multiple stories.
// ------------------------------------------------------------------------------------------

public class CustomViewBasedAuthorizationFilterDefinitionsFactory : IAuthorizationFilterDefinitionsFactory
{
// private readonly IDatabaseNamingConvention _databaseNamingConvention;
// private readonly AuthorizationContextDataFactory _authorizationContextDataFactory = new();
private readonly IDatabaseNamingConvention _databaseNamingConvention;

private const string TrackedChangesAlias = "c";

private readonly IEducationOrganizationIdNamesProvider _educationOrganizationIdNamesProvider;
private readonly IApiClientContextProvider _apiClientContextProvider;
private readonly IViewBasedSingleItemAuthorizationQuerySupport _viewBasedSingleItemAuthorizationQuerySupport;
private readonly ICustomViewBasisEntityProvider _customViewBasisEntityProvider;
private readonly ConcurrentDictionary<(Resource resource, string viewName), string> _nonIdentifyingPropertiesTextByResourceAndView = new();

public CustomViewBasedAuthorizationFilterDefinitionsFactory(
// IDatabaseNamingConvention databaseNamingConvention,
IEducationOrganizationIdNamesProvider educationOrganizationIdNamesProvider,
IApiClientContextProvider apiClientContextProvider,
IDatabaseNamingConvention databaseNamingConvention,
IViewBasedSingleItemAuthorizationQuerySupport viewBasedSingleItemAuthorizationQuerySupport,
ICustomViewBasisEntityProvider customViewBasisEntityProvider)
{
// _databaseNamingConvention = databaseNamingConvention;
_educationOrganizationIdNamesProvider = educationOrganizationIdNamesProvider;
_apiClientContextProvider = apiClientContextProvider;
_databaseNamingConvention = databaseNamingConvention;
_viewBasedSingleItemAuthorizationQuerySupport = viewBasedSingleItemAuthorizationQuerySupport;
_customViewBasisEntityProvider = customViewBasisEntityProvider;

// _oldNamespaceQueryColumnExpression = $"{TrackedChangesAlias}.{databaseNamingConvention.ColumnName($"OldNamespace")}";
}

public AuthorizationFilterDefinition CreateAuthorizationFilterDefinition(string filterName)
Expand Down Expand Up @@ -115,18 +101,6 @@ private InstanceAuthorizationResult AuthorizeInstance(
// whether the endpoint values are null or not.
return InstanceAuthorizationResult.NotPerformed();
}

// If the subject's endpoint name is an Education Organization Id, we can try to authenticate it here using claim values
if (_educationOrganizationIdNamesProvider.IsEducationOrganizationIdName(authorizationFilterContext.SubjectEndpointNames[i]))
{
// NOTE: Could consider caching the EdOrgToEdOrgId tuple table.
// If the EdOrgId values match, then we can report the filter as successfully authorized
if (_apiClientContextProvider.GetApiClientContext()
.EducationOrganizationIds.Contains((long) authorizationFilterContext.SubjectEndpointValues[i]))
{
return InstanceAuthorizationResult.Success();
}
}
}

return InstanceAuthorizationResult.NotPerformed();
Expand All @@ -140,45 +114,64 @@ private void ApplyTrackedChangesAuthorizationCriteria(
QueryBuilder queryBuilder,
bool useOuterJoins)
{
throw new NotSupportedException();

// if (filterContext.ClaimParameterValues.Length == 1)
// {
// if (filterContext.SubjectEndpointName == "Namespace")
// {
// queryBuilder.WhereLike($"{_oldNamespaceQueryColumnExpression}", filterContext.ClaimParameterValues.Single());
// }
// else
// {
// queryBuilder.WhereLike(
// $"{TrackedChangesAlias}.{_databaseNamingConvention.ColumnName($"Old{filterContext.SubjectEndpointName}")}",
// filterContext.ClaimParameterValues.Single());
// }
// }
// else if (filterContext.ClaimParameterValues.Length > 1)
// {
// queryBuilder.Where(
// q =>
// {
// if (filterContext.SubjectEndpointName == "Namespace")
// {
// filterContext.ClaimParameterValues.ForEach(ns => q.OrWhereLike(_oldNamespaceQueryColumnExpression, ns));
// }
// else
// {
// filterContext.ClaimParameterValues.ForEach(
// ns => q.OrWhereLike(
// $"{TrackedChangesAlias}.{_databaseNamingConvention.ColumnName($"Old{filterContext.SubjectEndpointName}")}",
// ns));
// }
//
// return q;
// });
// }
// else
// {
// // This should never happen
// throw new SecurityAuthorizationException(SecurityAuthorizationException.DefaultTechnicalProblemDetail, "No namespaces found in claims.");
// }
if (filterDefinition is not ViewBasedAuthorizationFilterDefinition viewBasedFilterDefinition)
{
throw new Exception($"Expected a view-based filter definition of type '{nameof(ViewBasedAuthorizationFilterDefinition)}'.");
}

string viewName = viewBasedFilterDefinition.ViewName;

string[] trackedChangesPropertyNames = resource.Entity.IsDerived
? filterContext.SubjectEndpointNames.Select(GetBasePropertyNameForSubjectEndpointName).ToArray()
: filterContext.SubjectEndpointNames;

if (useOuterJoins)
{
throw new InvalidOperationException("Outer joins are not used with custom view-based authorizations.");
}

// Verify that all the tracked changes property names are identifying (a requirement for tracked change queries)
string nonIdentifyPropertyNames = _nonIdentifyingPropertiesTextByResourceAndView.GetOrAdd(
(resource, viewBasedFilterDefinition.ViewName),
(x, propertyNames) =>
{
return string.Join(
"', '",
propertyNames.Where(
tcpn => !x.resource.Entity.Identifier.Properties.Any(p => p.PropertyName.EqualsIgnoreCase(tcpn))));
},
trackedChangesPropertyNames);

if (nonIdentifyPropertyNames.Length > 0)
{
throw new SecurityConfigurationException(
SecurityConfigurationException.DefaultDetail,
$"Non-identifying properties ('{nonIdentifyPropertyNames}') were found to be used for the custom view-based authorization strategy, but this is not supported by Change Queries which only tracks deleted/changed values of identifying properties. Should a different authorization strategy be used?");
}

queryBuilder.Join(
$"auth.{viewName} AS rba{filterIndex}",
j =>
{
for (int i = 0; i < trackedChangesPropertyNames.Length; i++)
{
j.On(
$"{TrackedChangesAlias}.{_databaseNamingConvention.ColumnName($"Old{trackedChangesPropertyNames[i]}")}",
$"rba{filterIndex}.{_databaseNamingConvention.ColumnName(viewBasedFilterDefinition.ViewTargetEndpointNames[i])}");
}

return j;
});

string GetBasePropertyNameForSubjectEndpointName(string filterContextSubjectEndpointName)
{
if (!resource.Entity.PropertyByName.TryGetValue(filterContextSubjectEndpointName, out var entityProperty))
{
throw new Exception(
$"Unable to find property '{filterContextSubjectEndpointName}' on entity '{resource.Entity.FullName}'.");
}

return entityProperty.BaseProperty.PropertyName;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@
using EdFi.Ods.Common.Security.Authorization;
using EdFi.Ods.Common.Security.Claims;

// ------------------------------------------------------------------------------------------
// TODO: ODS-6426, ODS-6427 - This file is a work-in-progress across multiple stories.
// ------------------------------------------------------------------------------------------

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters
{
public class ViewBasedAuthorizationFilterDefinition : AuthorizationFilterDefinition
Expand Down Expand Up @@ -69,8 +65,8 @@ public ViewBasedAuthorizationFilterDefinition(
authorizeInstance)
{
ViewName = viewName;
ViewSourceEndpointName = viewSourceEndpointName;
ViewTargetEndpointName = viewTargetEndpointName;
ViewSourceEndpointNames = [viewSourceEndpointName];
ViewTargetEndpointNames = [viewTargetEndpointName];
ViewBasedSingleItemAuthorizationQuerySupport = viewBasedSingleItemAuthorizationQuerySupport;
}

Expand All @@ -87,26 +83,19 @@ public ViewBasedAuthorizationFilterDefinition(
public ViewBasedAuthorizationFilterDefinition(
string filterName,
string viewName,
// string viewSourceEndpointName,
string[] viewTargetEndpointNames,
Action<AuthorizationFilterDefinition, AuthorizationFilterContext, Resource, int, QueryBuilder, bool> trackedChangesCriteriaApplicator,
Func<EdFiAuthorizationContext, AuthorizationFilterContext, string, InstanceAuthorizationResult> authorizeInstance,
IViewBasedSingleItemAuthorizationQuerySupport viewBasedSingleItemAuthorizationQuerySupport) //,
// IMultiValueRestrictions multiValueRestrictions)
IViewBasedSingleItemAuthorizationQuerySupport viewBasedSingleItemAuthorizationQuerySupport)
: base(
filterName,
$@"{{currentAlias}}.{{subjectEndpointName}} IN (
SELECT {{newAlias1}}.{viewTargetEndpointNames[0]} // TODO: Fix HQL
FROM {GetFullNameForView($"auth_{viewName}")} {{newAlias1}} )",
//WHERE {{newAlias1}}.{viewSourceEndpointName} IN (:{RelationshipAuthorizationConventions.ClaimsParameterName}))",
(criteria, @where, subjectEndpointNames, parameters, joinType, authorizationStrategy)
=> criteria.ApplyCustomViewJoinFilter(
// multiValueRestrictions,
// @where,
// parameters,
viewName,
subjectEndpointNames,
// viewSourceEndpointName,
viewTargetEndpointNames,
joinType,
Guid.NewGuid().ToString("N"),
Expand All @@ -115,17 +104,44 @@ public ViewBasedAuthorizationFilterDefinition(
authorizeInstance)
{
ViewName = viewName;
// ViewSourceEndpointName = viewSourceEndpointName;
ViewTargetEndpointNames = viewTargetEndpointNames;
ViewBasedSingleItemAuthorizationQuerySupport = viewBasedSingleItemAuthorizationQuerySupport;
}

public string ViewName { get; }

public string ViewSourceEndpointName { get; }
public string ViewTargetEndpointName { get; }
public string[] ViewSourceEndpointNames { get; }
public string[] ViewTargetEndpointNames { get; }

// Single-value property retained for backwards compatibility with existing use of single-column joins
public string ViewSourceEndpointName
{
get
{
return ViewSourceEndpointNames?.Length switch
{
0 => null,
1 => ViewSourceEndpointNames[0],
_ => throw new InvalidOperationException(
"Multiple view source endpoint names were found in the filter definition when exactly one was expected.")
};
}
}

// Single-value property retained for backwards compatibility with existing use of single-column joins
public string ViewTargetEndpointName
{
get
{
return ViewTargetEndpointNames?.Length switch
{
0 => null,
1 => ViewTargetEndpointNames[0],
_ => throw new InvalidOperationException(
"Multiple view target endpoint names were found in the filter definition when exactly one was expected.")
};
}
}

public IViewBasedSingleItemAuthorizationQuerySupport ViewBasedSingleItemAuthorizationQuerySupport { get; set; }

private static string GetFullNameForView(string viewName)
Expand Down
23 changes: 13 additions & 10 deletions Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,16 +131,19 @@ public QueryBuilder Where(Func<QueryBuilder, QueryBuilder> nestedWhereApplicator
return this;
}

var template = childScopeSqlBuilder.AddTemplate(
"/**where**/",
childScope.Parameters.Any()
? new DynamicParameters(childScope.Parameters)
: null);

// Wrap the WHERE clause directly
_sqlBuilder.Where($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters);
if (childScopeSqlBuilder.HasWhereClause())
{
var template = childScopeSqlBuilder.AddTemplate(
"/**where**/",
childScope.Parameters.Any()
? new DynamicParameters(childScope.Parameters)
: null);

// Wrap the WHERE clause directly
_sqlBuilder.Where($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters);
}

// Incorporate the JOINs into this builder
// Incorporate any JOINs added into this builder
_sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "innerjoin", "leftjoin", "rightjoin", "join");

return this;
Expand Down Expand Up @@ -325,7 +328,7 @@ public QueryBuilder Distinct()

return this;
}

public QueryBuilder LimitOffset(int limit, int offset = 0)
{
// Apply paging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,31 @@ public bool IsEmpty()
{
return _data.Count == 0;
}

public bool HasWhereClause()
{
return HasClause(ClauseKey.Where);
}

private bool HasClause(string clausekey)
{
return _data.Any(kvp => kvp.Key == clausekey);
}
}

public static class ClauseKey
{
public static string Intersect = "intersect";
public static string InnerJoin = "innerjoin";
public static string LeftJoin = "leftjoin";
public static string RightJoin = "rightjoin";
public static string Where = "where";
public static string OrderBy = "orderby";
public static string Select = "select";
public static string Parameters = "--parameters";
public static string Join = "join";
public static string GroupBy = "groupby";
public static string Having = "having";
public static string Set = "set";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using EdFi.Ods.Common.Exceptions;
using EdFi.Ods.Common.Models;
using EdFi.Ods.Common.Models.Domain;
using EdFi.Ods.Common.Security.Claims;
Expand Down Expand Up @@ -83,7 +84,8 @@ List<string> GetAuthorizationSubjectEndpointNames()
continue;
}

throw new Exception(
throw new SecurityConfigurationException(
SecurityConfigurationException.DefaultDetail,
$"Unable to find a property on the authorization subject entity type '{subjectEntity.Name}' corresponding to the '{basisProperty.PropertyName}' property on the custom authorization view's basis entity type '{_basisEntity.Name}' in order to perform authorization. Should a different authorization strategy be used?");
}

Expand Down
Loading

0 comments on commit 579545e

Please sign in to comment.