Skip to content

Commit

Permalink
[ODS-6285] Add hints to relationship-based authorization failure mess…
Browse files Browse the repository at this point in the history
…ages (#987)

Co-authored-by: Jesus Flores <[email protected]>
  • Loading branch information
gmcelhanon and simpat-jesus authored Mar 18, 2024
1 parent 3d7d611 commit 6dce58d
Show file tree
Hide file tree
Showing 15 changed files with 776 additions and 107 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 "You may need to create corresponding 'StudentSchoolAssociation' and 'StudentContactAssociation' resource items.";
}

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 "You may need to create corresponding 'StudentSchoolAssociation' and 'StudentParentAssociation' resource items.";
}

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 "You may need to create corresponding 'StaffEducationOrganizationEmploymentAssociation' or 'StaffEducationOrganizationAssignmentAssociation' resource items.";
}

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 "You may need to create a corresponding 'StudentSchoolAssociation' resource item.";
}

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 "You may need to create a corresponding 'StudentEducationOrganizationResponsibilityAssociation' resource item.";
}

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 6dce58d

Please sign in to comment.