Skip to content

Commit

Permalink
Implemented relationship-based auth view failure hints, and made auth…
Browse files Browse the repository at this point in the history
…orization failure messages returned when a referenced person exists indistinguishable from when it does not (missed ODS-6031 requirement).
  • Loading branch information
gmcelhanon committed Feb 29, 2024
1 parent 273cf61 commit 16df015
Show file tree
Hide file tree
Showing 9 changed files with 229 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using EdFi.Common.Utils.Extensions;
using EdFi.Ods.Api.Security.Authorization.Filtering;
using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters;
using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;
using EdFi.Ods.Common;
using EdFi.Ods.Common.Context;
using EdFi.Ods.Common.Exceptions;
Expand All @@ -41,6 +42,7 @@ public class EntityAuthorizer : IEntityAuthorizer
private readonly IViewBasedSingleItemAuthorizationQuerySupport _viewBasedSingleItemAuthorizationQuerySupport;
private readonly IContextProvider<DataManagementResourceContext> _dataManagementResourceContextProvider;
private readonly IContextProvider<ViewBasedAuthorizationQueryContext> _viewBasedAuthorizationQueryContextProvider;
private readonly IAuthorizationViewHintProvider[] _authorizationViewHintProviders;
private readonly ISessionFactory _sessionFactory;

private readonly Lazy<Dictionary<string, Actions>> _bitValuesByAction;
Expand All @@ -53,7 +55,7 @@ private enum Actions
Update = 0x4,
Delete = 0x8,
}

public EntityAuthorizer(
IAuthorizationContextProvider authorizationContextProvider,
IAuthorizationFilteringProvider authorizationFilteringProvider,
Expand All @@ -65,7 +67,8 @@ public EntityAuthorizer(
ISessionFactory sessionFactory,
ISecurityRepository securityRepository,
IContextProvider<DataManagementResourceContext> dataManagementResourceContextProvider,
IContextProvider<ViewBasedAuthorizationQueryContext> viewBasedAuthorizationQueryContextProvider)
IContextProvider<ViewBasedAuthorizationQueryContext> viewBasedAuthorizationQueryContextProvider,
IAuthorizationViewHintProvider[] authorizationViewHintProviders)
{
_authorizationContextProvider = authorizationContextProvider;
_authorizationFilteringProvider = authorizationFilteringProvider;
Expand All @@ -76,6 +79,7 @@ public EntityAuthorizer(
_viewBasedSingleItemAuthorizationQuerySupport = viewBasedSingleItemAuthorizationQuerySupport;
_dataManagementResourceContextProvider = dataManagementResourceContextProvider;
_viewBasedAuthorizationQueryContextProvider = viewBasedAuthorizationQueryContextProvider;
_authorizationViewHintProviders = authorizationViewHintProviders;
_sessionFactory = sessionFactory;

// Lazy initialization
Expand Down Expand Up @@ -256,60 +260,50 @@ private async Task PerformViewBasedAuthorizationAsync(
EdFiAuthorizationContext authorizationContext,
CancellationToken cancellationToken)
{
string sql = BuildExistenceCheckSql(resultsWithPendingExistenceChecks);

// Execute the query
using var sessionScope = new SessionScope(_sessionFactory);

await using var cmd = sessionScope.Session.Connection.CreateCommand();
sessionScope.Session.GetCurrentTransaction()?.Enlist(cmd);

// Assign the command text
cmd.CommandText = sql;

// Assign all parameters
var parameters =
resultsWithPendingExistenceChecks.SelectMany(
x => x.FilterResults.Select(
f =>
{
var parameter = cmd.CreateParameter();
parameter.ParameterName = f.FilterContext.SubjectEndpointName;
parameter.Value = f.FilterContext.SubjectEndpointValue;

return parameter;
}))
.GroupBy(x => x.ParameterName)
.Select(x => x.First())
.ToArray();
// Before building and executing authorization SQL, check for null values on subject endpoints
var parameterDetails = resultsWithPendingExistenceChecks.SelectMany(
x => x.FilterResults.Select(f => (ParameterName: f.FilterContext.SubjectEndpointName, ParameterValue: f.FilterContext.SubjectEndpointValue)))
.GroupBy(x => x.ParameterName)
.Select(x => x.First())
.ToArray();

cmd.Parameters.AddRange(parameters);
int? validationResult = 0;

// Check for previous identical execution in current context
var viewBasedAuthorizationQueryContext = _viewBasedAuthorizationQueryContextProvider.Get();
// Ensure all the parameter values actually have values before hitting the database...
if (parameterDetails.All(pd => pd.ParameterValue != null))
{
validationResult = await ExecuteSingleItemAuthorizationQuery();
}

if (IsRedundantAuthorizationForCurrentContext())
// Result will be null if it's redundant with an earlier check already performed in this call context
if (validationResult == null)
{
return;
}

_viewBasedSingleItemAuthorizationQuerySupport.ApplyClaimsParametersToCommand(cmd, authorizationContext);
if (validationResult == 0)
{
var authorizationViewNames = resultsWithPendingExistenceChecks.SelectMany(
x => x.FilterResults.Select(f => f.FilterDefinition)
.OfType<ViewBasedAuthorizationFilterDefinition>()
.Select(f => f.ViewName))
.Distinct();

var hints = authorizationViewNames.SelectMany(
v => _authorizationViewHintProviders.Select(p => p.GetFailureHint(v)).Where(h => !string.IsNullOrEmpty(h)))
.Distinct()
.ToArray();

// Process the pending AND SQL checks, ensure that they are all 1, or throw an exception
var result = (int?)await cmd.ExecuteScalarAsync(cancellationToken);
if (hints.Any())
{
throw new SecurityAuthorizationException(
$"{SecurityAuthorizationException.DefaultDetail} Hint: {string.Join(" ", hints)}",
GetAuthorizationFailureMessage());
}

if (result == 0)
{
throw new SecurityAuthorizationException(SecurityAuthorizationException.DefaultDetail, GetAuthorizationFailureMessage());
}

// Save the SQL and parameters for this query execution into the current context (if context is present but uninitialized)
if (viewBasedAuthorizationQueryContext is { Sql: null })
{
viewBasedAuthorizationQueryContext.Sql = cmd.CommandText;
viewBasedAuthorizationQueryContext.Parameters = parameters;
}

string BuildExistenceCheckSql(AuthorizationStrategyFilterResults[] resultsWithPendingExistenceChecks)
{
// Build the existence check SQL
Expand Down Expand Up @@ -415,28 +409,76 @@ string GetClaimEndpointValuesText(string[] claimEndpointValuesAsStrings, int max
return claimEndpointValuesText;
}

bool IsRedundantAuthorizationForCurrentContext()
async Task<int?> ExecuteSingleItemAuthorizationQuery()
{
// Has the context been set (indicating we're upserting) and is the current SQL already present?
if (viewBasedAuthorizationQueryContext != null && viewBasedAuthorizationQueryContext.Sql == sql)
string sql = BuildExistenceCheckSql(resultsWithPendingExistenceChecks);

// Execute the query
using var sessionScope = new SessionScope(_sessionFactory);

await using var cmd = sessionScope.Session.Connection.CreateCommand();
sessionScope.Session.GetCurrentTransaction()?.Enlist(cmd);

// Assign the command text
cmd.CommandText = sql;

// Assign all parameters
var parameters = parameterDetails.Select(pd => {
var parameter = cmd.CreateParameter();
parameter.ParameterName = pd.ParameterName;
parameter.Value = pd.ParameterValue;

return parameter;
})
.ToArray();

cmd.Parameters.AddRange(parameters);

// Check for previous identical execution in current context
var viewBasedAuthorizationQueryContext = _viewBasedAuthorizationQueryContextProvider.Get();

if (IsRedundantAuthorizationForCurrentContext())
{
// Check the parameters for equality
for (int i = 0; i < parameters.Length; i++)
return null;
}

_viewBasedSingleItemAuthorizationQuerySupport.ApplyClaimsParametersToCommand(cmd, authorizationContext);

// Process the pending AND SQL checks to get a result (0 for failure, 1 for success)
validationResult = (int?) await cmd.ExecuteScalarAsync(cancellationToken) ?? 0;

// Save the SQL and parameters for this query execution into the current context (if context is present but uninitialized)
if (viewBasedAuthorizationQueryContext is { Sql: null })
{
viewBasedAuthorizationQueryContext.Sql = cmd.CommandText;
viewBasedAuthorizationQueryContext.Parameters = parameters;
}

return validationResult;

bool IsRedundantAuthorizationForCurrentContext()
{
// Has the context been set (indicating we're upserting) and is the current SQL already present?
if (viewBasedAuthorizationQueryContext != null && viewBasedAuthorizationQueryContext.Sql == sql)
{
// Do parameter values differ?
if (!parameters[i].Value.Equals(viewBasedAuthorizationQueryContext.Parameters[i].Value))
// Check the parameters for equality
for (int i = 0; i < parameters.Length; i++)
{
// Authorization is not redundant
return false;
// Do parameter values differ?
if (!parameters[i].Value.Equals(viewBasedAuthorizationQueryContext.Parameters[i].Value))
{
// Authorization is not redundant
return false;
}
}

// SQL and parameters match - authorization is redundant
return true;
}

// SQL and parameters match - authorization is redundant
return true;
// No context present, or SQL doesn't match -- not redundant
return false;
}

// No context present, or SQL doesn't match -- not redundant
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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.

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

public class EducationOrganizationIdToContactUsiAuthorizationViewHintProvider : IAuthorizationViewHintProvider
{
public string GetFailureHint(string viewName)
{
if (viewName.StartsWith("EducationOrganizationIdToContactUSI"))
{
return "Ensure that the corresponding 'StudentSchoolAssociation' and 'StudentContactAssociation' resource items have been created.";
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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;

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

public class EducationOrganizationIdToParentUsiAuthorizationViewHintProvider : IAuthorizationViewHintProvider
{
public string GetFailureHint(string viewName)
{
if (viewName.StartsWith("EducationOrganizationIdToParentUSI", StringComparison.OrdinalIgnoreCase))
{
return "Ensure that the corresponding 'StudentSchoolAssociation' and 'StudentParentAssociation' resource items have been created.";
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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;

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

public class EducationOrganizationIdToStaffUsiAuthorizationViewHintProvider : IAuthorizationViewHintProvider
{
public string GetFailureHint(string viewName)
{
if (viewName.StartsWith("EducationOrganizationIdToStaffUSI", StringComparison.OrdinalIgnoreCase))
{
return "Ensure that a corresponding 'StaffEducationOrganizationEmploymentAssociation' or 'StaffEducationOrganizationAssignmentAssociation' resource item has been created.";
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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;

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

public class EducationOrganizationIdToStudentUsiAuthorizationViewHintProvider : IAuthorizationViewHintProvider
{
public string GetFailureHint(string viewName)
{
if (viewName.Equals("EducationOrganizationIdToStudentUSI", StringComparison.OrdinalIgnoreCase)
|| viewName.Equals("EducationOrganizationIdToStudentUSIIncludingDeletes", StringComparison.OrdinalIgnoreCase))
{
return "Ensure that a corresponding 'StudentSchoolAssociation' resource item has been created.";
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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;

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

public class EducationOrganizationIdToStudentUsiThroughResponsibilityAuthorizationViewHintProvider
: IAuthorizationViewHintProvider
{
public string GetFailureHint(string viewName)
{
if (viewName.StartsWith("EducationOrganizationIdToStudentUSIThrough", StringComparison.OrdinalIgnoreCase)
&& viewName.EndsWith("Responsibility", StringComparison.OrdinalIgnoreCase))
{
return "Ensure that a corresponding 'StudentEducationOrganizationResponsibilityAssociation' resource item has been created.";
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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.

namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints;

/// <summary>
/// Defines a method for obtaining a hint to be included with the Problem Details response when view-based authorization fails.
/// </summary>
public interface IAuthorizationViewHintProvider
{
string GetFailureHint(string viewName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,9 @@ private InstanceAuthorizationResult AuthorizeInstance(
{
if (filterContext.SubjectEndpointValue == null)
{
if (filterContext.SubjectEndpointName.EndsWith("USI"))
{
var subjectSubstring = filterContext.SubjectEndpointName.Substring(
0, filterContext.SubjectEndpointName.Length - 3);

throw new SecurityAuthorizationException(
SecurityAuthorizationException.DefaultDetail,
$"Either the referenced '{subjectSubstring}' was not found or no relationships have been established between the caller's education organization id claims ({string.Join(", ", filterContext.ClaimParameterValues)}) and the referenced '{subjectSubstring}'.");
}

throw new SecurityAuthorizationException(
SecurityAuthorizationException.DefaultDetail,
$"Access to the resource item could not be authorized because the '{filterContext.SubjectEndpointName}' of the resource is empty.");
// We will defer to the final authorization check to produce identical messages
// 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.
Expand Down
Loading

0 comments on commit 16df015

Please sign in to comment.