diff --git a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/EntityAuthorizer.cs b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/EntityAuthorizer.cs index 6259925a5c..1cf8bc8863 100644 --- a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/EntityAuthorizer.cs +++ b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/EntityAuthorizer.cs @@ -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; @@ -41,6 +42,7 @@ public class EntityAuthorizer : IEntityAuthorizer private readonly IViewBasedSingleItemAuthorizationQuerySupport _viewBasedSingleItemAuthorizationQuerySupport; private readonly IContextProvider _dataManagementResourceContextProvider; private readonly IContextProvider _viewBasedAuthorizationQueryContextProvider; + private readonly IAuthorizationViewHintProvider[] _authorizationViewHintProviders; private readonly ISessionFactory _sessionFactory; private readonly Lazy> _bitValuesByAction; @@ -53,7 +55,7 @@ private enum Actions Update = 0x4, Delete = 0x8, } - + public EntityAuthorizer( IAuthorizationContextProvider authorizationContextProvider, IAuthorizationFilteringProvider authorizationFilteringProvider, @@ -65,7 +67,8 @@ public EntityAuthorizer( ISessionFactory sessionFactory, ISecurityRepository securityRepository, IContextProvider dataManagementResourceContextProvider, - IContextProvider viewBasedAuthorizationQueryContextProvider) + IContextProvider viewBasedAuthorizationQueryContextProvider, + IAuthorizationViewHintProvider[] authorizationViewHintProviders) { _authorizationContextProvider = authorizationContextProvider; _authorizationFilteringProvider = authorizationFilteringProvider; @@ -76,6 +79,7 @@ public EntityAuthorizer( _viewBasedSingleItemAuthorizationQuerySupport = viewBasedSingleItemAuthorizationQuerySupport; _dataManagementResourceContextProvider = dataManagementResourceContextProvider; _viewBasedAuthorizationQueryContextProvider = viewBasedAuthorizationQueryContextProvider; + _authorizationViewHintProviders = authorizationViewHintProviders; _sessionFactory = sessionFactory; // Lazy initialization @@ -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() + .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 @@ -415,28 +409,76 @@ string GetClaimEndpointValuesText(string[] claimEndpointValuesAsStrings, int max return claimEndpointValuesText; } - bool IsRedundantAuthorizationForCurrentContext() + async Task 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; } } } diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToContactUsiAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToContactUsiAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..92410658c4 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToContactUsiAuthorizationViewHintProvider.cs @@ -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; + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToParentUsiAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToParentUsiAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..851ee58c85 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToParentUsiAuthorizationViewHintProvider.cs @@ -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; + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStaffUsiAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStaffUsiAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..5037d7c933 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStaffUsiAuthorizationViewHintProvider.cs @@ -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; + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..7c56d67de2 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiAuthorizationViewHintProvider.cs @@ -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; + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiThroughResponsibilityAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiThroughResponsibilityAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..8fbea04f61 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/EducationOrganizationIdToStudentUsiThroughResponsibilityAuthorizationViewHintProvider.cs @@ -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; + } +} diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/IAuthorizationViewHintProvider.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/IAuthorizationViewHintProvider.cs new file mode 100644 index 0000000000..a558c1a9d2 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/Hints/IAuthorizationViewHintProvider.cs @@ -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; + +/// +/// Defines a method for obtaining a hint to be included with the Problem Details response when view-based authorization fails. +/// +public interface IAuthorizationViewHintProvider +{ + string GetFailureHint(string viewName); +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/RelationshipsAuthorizationStrategyFilterDefinitionsFactory.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/RelationshipsAuthorizationStrategyFilterDefinitionsFactory.cs index 73a49b31a2..a9b8f4746a 100644 --- a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/RelationshipsAuthorizationStrategyFilterDefinitionsFactory.cs +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/RelationshipsAuthorizationStrategyFilterDefinitionsFactory.cs @@ -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. diff --git a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityModule.cs b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityModule.cs index 309acb6024..73931e5413 100644 --- a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityModule.cs +++ b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityModule.cs @@ -16,6 +16,7 @@ using EdFi.Ods.Api.Security.Authorization.Repositories; using EdFi.Ods.Api.Security.AuthorizationStrategies; using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships; +using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters.Hints; using EdFi.Ods.Api.Security.Claims; using EdFi.Ods.Api.Security.Utilities; @@ -83,6 +84,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType() .As() .SingleInstance(); + + builder.RegisterAssemblyTypes(typeof(Marker_EdFi_Ods_Api).Assembly) + .Where(t => typeof(IAuthorizationViewHintProvider).IsAssignableFrom(t)) + .As() + .SingleInstance(); builder.RegisterType() .As()