Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Auth/IAuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ public interface IAuthorizationResolver
/// <returns>The policy text and typed claim values to bind to it.</returns>
public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName, EntityActionOperation operation, HttpContext httpContext);

/// <summary>Audits a configured policy that produced no predicate and rejects the request outside development mode.</summary>
public void AuditDatabasePolicyFailOpen(string entityName, string roleName, EntityActionOperation operation, string configuredPolicy);

/// <summary>
/// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization
/// directive on Object type.
Expand Down
91 changes: 89 additions & 2 deletions src/Core/Authorization/AuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System.Globalization;
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Azure.DataApiBuilder.Auth;
Expand All @@ -16,6 +18,7 @@
using Azure.DataApiBuilder.Service.Exceptions;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.JsonWebTokens;

Expand All @@ -29,6 +32,7 @@ public class AuthorizationResolver : IAuthorizationResolver
{
private readonly RuntimeConfigProvider _runtimeConfigProvider;
private readonly IMetadataProviderFactory _metadataProviderFactory;
private readonly ILogger<IAuthorizationResolver>? _logger;
public const string WILDCARD = "*";
public const string CLAIM_PREFIX = "@claims.";
public const string FIELD_PREFIX = "@item.";
Expand All @@ -49,10 +53,12 @@ public class AuthorizationResolver : IAuthorizationResolver
public AuthorizationResolver(
RuntimeConfigProvider runtimeConfigProvider,
IMetadataProviderFactory metadataProviderFactory,
HotReloadEventHandler<HotReloadEventArgs>? handler = null)
HotReloadEventHandler<HotReloadEventArgs>? handler = null,
ILogger<IAuthorizationResolver>? logger = null)
{
_metadataProviderFactory = metadataProviderFactory;
_runtimeConfigProvider = runtimeConfigProvider;
_logger = logger;
handler?.Subscribe(DabConfigEvents.AUTHZ_RESOLVER_ON_CONFIG_CHANGED, OnConfigChanged);

if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig))
Expand Down Expand Up @@ -216,7 +222,17 @@ public ResolvedDatabasePolicy ResolveDBPolicy(string entityName, string roleName
return ResolvedDatabasePolicy.Empty;
}

return GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllAuthenticatedUserClaims(httpContext));
ResolvedDatabasePolicy resolvedPolicy = GetPolicyWithClaimValues(dBpolicyWithClaimTypes, GetAllAuthenticatedUserClaims(httpContext));

// A configured (non-empty) database policy that compiles to an empty predicate is a
// silent fail-open of row/column level authorization: the query would run unfiltered.
// Emit an audit signal and, outside development mode, fail the request.
if (string.IsNullOrWhiteSpace(resolvedPolicy.Policy))
{
AuditDatabasePolicyFailOpen(entityName, roleName, operation, dBpolicyWithClaimTypes);
Comment thread
RubenCerna2079 marked this conversation as resolved.
}

return resolvedPolicy;
}

/// <inheritdoc />
Expand Down Expand Up @@ -794,6 +810,77 @@ private static ResolvedDatabasePolicy GetPolicyWithClaimValues(string policy, Di
return new ResolvedDatabasePolicy(processedPolicy, claimValues);
}

/// <summary>
/// Emits a structured audit signal when a configured (non-empty) database authorization
/// policy resolves to an empty predicate, which silently removes the intended row/column
/// level filter (fail-open). In development mode this is logged as a warning so the request
/// can proceed for debugging; in all other host modes it is logged as an error and the
/// request is failed to prevent returning unfiltered data.
/// </summary>
/// <param name="entityName">Entity targeted by the request.</param>
/// <param name="roleName">Role resolved from the client role header.</param>
/// <param name="operation">Operation the policy applies to.</param>
/// <param name="configuredPolicy">The non-empty policy text configured in runtime config.</param>
/// <exception cref="DataApiBuilderException">Thrown outside development mode to fail the request.</exception>
public void AuditDatabasePolicyFailOpen(
string entityName,
string roleName,
EntityActionOperation operation,
string configuredPolicy)
{
const string reason = "Configured database authorization policy compiled to an empty predicate; " +
"the request would run without the intended row/column level filter (fail-open).";
string predicateHash = ComputePredicateHash(configuredPolicy);
bool isDevelopmentMode = _runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)
&& runtimeConfig.IsDevelopmentMode();

// Structured audit event: entity, role, operation, policy text, reason, predicateHash, dbPolicyApplied.
const string auditMessageTemplate = "Database authorization policy fail-open detected. " +
"Entity: {Entity}, Role: {Role}, Operation: {Operation}, PolicyText: {PolicyText}, " +
"Reason: {Reason}, PredicateHash: {PredicateHash}, DbPolicyApplied: {DbPolicyApplied}";

if (isDevelopmentMode)
{
_logger?.LogWarning(
auditMessageTemplate,
entityName,
roleName,
operation,
configuredPolicy,
reason,
predicateHash,
false);
return;
}

_logger?.LogError(
auditMessageTemplate,
entityName,
roleName,
operation,
configuredPolicy,
reason,
predicateHash,
false);

throw new DataApiBuilderException(
message: DataApiBuilderException.AUTHORIZATION_FAILURE,
statusCode: HttpStatusCode.Forbidden,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure);
}

/// <summary>
/// Produces a stable, non-reversible fingerprint of a policy's text so audit signals can be
/// correlated across requests without logging claim values or other sensitive data.
/// </summary>
/// <param name="policyText">Configured policy text to hash.</param>
/// <returns>Uppercase hexadecimal SHA-256 hash of the policy text.</returns>
private static string ComputePredicateHash(string policyText)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(policyText));
return Convert.ToHexString(hash);
}

/// <summary>
/// Helper function used to retrieve the claim value for the given claim type from the user's claims.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/Core/Resolvers/AuthorizationPolicyHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ private static List<FilterClause> ProcessFilter(
resourcePath: (entityDBObject is not null) ? $"{entityName}.{entityDBObject.FullName}" : entityName,
sqlMetadataProvider);

if (filterClause is null && !string.IsNullOrWhiteSpace(dbQueryPolicy.Policy))
{
string configuredPolicy = authorizationResolver.GetDBPolicyForRequest(entityName, clientRoleHeader, elementalOperation);
if (!string.IsNullOrWhiteSpace(configuredPolicy))
{
authorizationResolver.AuditDatabasePolicyFailOpen(entityName, clientRoleHeader, elementalOperation, configuredPolicy);
}
}

postProcessCallback(filterClause, elementalOperation);
}

Expand Down
6 changes: 3 additions & 3 deletions src/Service.Tests/Authorization/AuthorizationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
using Azure.DataApiBuilder.Config.ObjectModel;
Expand Down Expand Up @@ -33,15 +34,14 @@
/// </summary>
/// <param name="runtimeConfig">Configuration with Authorization metadata</param>
/// <returns>AuthorizationResolver object</returns>
public static AuthorizationResolver InitAuthorizationResolver(RuntimeConfig runtimeConfig)
public static AuthorizationResolver InitAuthorizationResolver(RuntimeConfig runtimeConfig, ILogger<IAuthorizationResolver>? logger = null)
{
MockFileSystem fileSystem = new();
fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new(runtimeConfig.ToJson()));
FileSystemRuntimeConfigLoader loader = new(fileSystem);

RuntimeConfigProvider runtimeConfigProvider = TestHelper.GetRuntimeConfigProvider(loader);
Mock<ISqlMetadataProvider> metadataProvider = new();
Mock<ILogger<AuthorizationResolver>> logger = new();
SourceDefinition sampleTable = CreateSampleTable();
metadataProvider.Setup(x => x.GetSourceDefinition(TEST_ENTITY)).Returns(sampleTable);
metadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL);
Expand All @@ -58,7 +58,7 @@
string dataSourceName = runtimeConfigProvider.GetConfig().DefaultDataSourceName;
metadataProviderFactory.Setup(x => x.GetMetadataProvider(dataSourceName)).Returns(metadataProvider.Object);

return new AuthorizationResolver(runtimeConfigProvider, metadataProviderFactory.Object);
return new AuthorizationResolver(runtimeConfigProvider, metadataProviderFactory.Object, logger);

Check failure on line 61 in src/Service.Tests/Authorization/AuthorizationHelpers.cs

View check run for this annotation

Azure Pipelines / MY_SQL Integration Tests

src/Service.Tests/Authorization/AuthorizationHelpers.cs#L61

src/Service.Tests/Authorization/AuthorizationHelpers.cs(61,101): Error CS1503: Argument 3: cannot convert from 'Microsoft.Extensions.Logging.ILogger<Azure.DataApiBuilder.Auth.IAuthorizationResolver>' to 'Azure.DataApiBuilder.Config.HotReloadEventHandler<Azure.DataApiBuilder.Config.HotReloadEventArgs>?'

Check failure on line 61 in src/Service.Tests/Authorization/AuthorizationHelpers.cs

View check run for this annotation

Azure Pipelines / DW_SQL Integration Tests (linux)

src/Service.Tests/Authorization/AuthorizationHelpers.cs#L61

src/Service.Tests/Authorization/AuthorizationHelpers.cs(61,101): Error CS1503: Argument 3: cannot convert from 'Microsoft.Extensions.Logging.ILogger<Azure.DataApiBuilder.Auth.IAuthorizationResolver>' to 'Azure.DataApiBuilder.Config.HotReloadEventHandler<Azure.DataApiBuilder.Config.HotReloadEventArgs>?'

Check failure on line 61 in src/Service.Tests/Authorization/AuthorizationHelpers.cs

View check run for this annotation

Azure Pipelines / PG_SQL Integration Tests

src/Service.Tests/Authorization/AuthorizationHelpers.cs#L61

src/Service.Tests/Authorization/AuthorizationHelpers.cs(61,101): Error CS1503: Argument 3: cannot convert from 'Microsoft.Extensions.Logging.ILogger<Azure.DataApiBuilder.Auth.IAuthorizationResolver>' to 'Azure.DataApiBuilder.Config.HotReloadEventHandler<Azure.DataApiBuilder.Config.HotReloadEventArgs>?'

Check failure on line 61 in src/Service.Tests/Authorization/AuthorizationHelpers.cs

View check run for this annotation

Azure Pipelines / DW_SQL Integration Tests

src/Service.Tests/Authorization/AuthorizationHelpers.cs#L61

src/Service.Tests/Authorization/AuthorizationHelpers.cs(61,101): Error CS1503: Argument 3: cannot convert from 'Microsoft.Extensions.Logging.ILogger<Azure.DataApiBuilder.Auth.IAuthorizationResolver>' to 'Azure.DataApiBuilder.Config.HotReloadEventHandler<Azure.DataApiBuilder.Config.HotReloadEventArgs>?'
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Auth;
Expand All @@ -17,6 +18,7 @@
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.Tests.Authentication.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -34,6 +36,91 @@ public class AuthorizationResolverUnitTests
private const string TEST_AUTHENTICATION_TYPE = "TestAuth";
private const string TEST_CLAIMTYPE_NAME = "TestName";

/// <summary>Verifies empty resolved policies emit an audit log and fail closed only in production.</summary>
[DataTestMethod]
[DataRow(HostMode.Development, EntityActionOperation.Read, "@item.", DisplayName = "Empty Predicate in Development read logs a warning")]
[DataRow(HostMode.Development, EntityActionOperation.Update, "@item.", DisplayName = "Empty Predicate in Development update logs a warning")]
[DataRow(HostMode.Development, EntityActionOperation.Delete, "@item.", DisplayName = "Empty Predicate in Development delete logs a warning")]
[DataRow(HostMode.Production, EntityActionOperation.Read, "@item.", DisplayName = "Empty Predicate in Production read logs an error and fails")]
[DataRow(HostMode.Production, EntityActionOperation.Update, "@item.", DisplayName = "Empty Predicate in Production update logs an error and fails")]
[DataRow(HostMode.Production, EntityActionOperation.Delete, "@item.", DisplayName = "Empty Predicate in Production delete logs an error and fails")]
[DataRow(HostMode.Development, EntityActionOperation.Read, "@item. @item.", DisplayName = "Empty Predicate in Development whitespace predicate logs a warning")]
[DataRow(HostMode.Production, EntityActionOperation.Read, "@item. @item.", DisplayName = "Empty Predicate in Production whitespace predicate logs an error and fails")]
public void ResolveDBPolicy_EmptyPredicate_AuditsAccordingToHostMode(
HostMode hostMode, EntityActionOperation operation, string configuredPolicy)
{
RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig(
roleName: TEST_ROLE, operation: operation, databasePolicy: configuredPolicy);
config = config with
{
Runtime = config.Runtime! with { Host = config.Runtime.Host! with { Mode = hostMode } }
};
Mock<ILogger<IAuthorizationResolver>> logger = new();
AuthorizationResolver resolver = AuthorizationHelpers.InitAuthorizationResolver(config, logger.Object);
DefaultHttpContext context = new();
context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = TEST_ROLE;

if (hostMode == HostMode.Development)
{
ResolvedDatabasePolicy resolved = resolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, operation, context);
Assert.IsTrue(string.IsNullOrWhiteSpace(resolved.Policy));
Assert.AreEqual(0, resolved.ClaimValues.Count);
}
else
{
DataApiBuilderException exception = Assert.ThrowsException<DataApiBuilderException>(() =>
resolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, operation, context));
Assert.AreEqual(HttpStatusCode.Forbidden, exception.StatusCode);
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure, exception.SubStatusCode);
Assert.AreEqual(DataApiBuilderException.AUTHORIZATION_FAILURE, exception.Message);
}

var log = logger.Invocations.Single(invocation => invocation.Method.Name == nameof(ILogger.Log));
Assert.AreEqual(hostMode == HostMode.Development ? LogLevel.Warning : LogLevel.Error, log.Arguments[0]);
Dictionary<string, object?> audit = ((IEnumerable<KeyValuePair<string, object?>>)log.Arguments[2])
.ToDictionary(property => property.Key, property => property.Value);
Assert.AreEqual(TEST_ENTITY, audit["Entity"]);
Assert.AreEqual(TEST_ROLE, audit["Role"]);
Assert.AreEqual(operation, audit["Operation"]);
Assert.AreEqual(configuredPolicy, audit["PolicyText"]);
Assert.AreEqual(false, audit["DbPolicyApplied"]);
Assert.AreEqual(
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(configuredPolicy))),
audit["PredicateHash"]);
StringAssert.Contains((string)audit["Reason"]!, "compiled to an empty predicate");
}

/// <summary>Verifies valid or unconfigured policies do not produce false fail-open audit logs.</summary>
[DataTestMethod]
[DataRow(HostMode.Development, null, "", DisplayName = "ResolveDBPolicy Development missing policy does not log")]
[DataRow(HostMode.Production, null, "", DisplayName = "ResolveDBPolicy Production missing policy does not log")]
[DataRow(HostMode.Development, "", "", DisplayName = "ResolveDBPolicy Development empty policy does not log")]
[DataRow(HostMode.Production, "", "", DisplayName = "ResolveDBPolicy Production empty policy does not log")]
[DataRow(HostMode.Development, " ", "", DisplayName = "ResolveDBPolicy Development whitespace configuration does not log")]
[DataRow(HostMode.Production, " ", "", DisplayName = "ResolveDBPolicy Production whitespace configuration does not log")]
[DataRow(HostMode.Development, "@item.col1 eq 1", "col1 eq 1", DisplayName = "ResolveDBPolicy Development valid policy does not log")]
[DataRow(HostMode.Production, "@item.col1 eq 1", "col1 eq 1", DisplayName = "ResolveDBPolicy Production valid policy does not log")]
public void ResolveDBPolicy_ValidOrUnconfiguredPolicy_DoesNotAudit(
HostMode hostMode, string? configuredPolicy, string expectedPolicy)
{
RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig(
roleName: TEST_ROLE, operation: EntityActionOperation.Read, databasePolicy: configuredPolicy);
config = config with
{
Runtime = config.Runtime! with { Host = config.Runtime.Host! with { Mode = hostMode } }
};
Mock<ILogger<IAuthorizationResolver>> logger = new();
AuthorizationResolver resolver = AuthorizationHelpers.InitAuthorizationResolver(config, logger.Object);
DefaultHttpContext context = new();
context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = TEST_ROLE;

ResolvedDatabasePolicy resolved = resolver.ResolveDBPolicy(TEST_ENTITY, TEST_ROLE, EntityActionOperation.Read, context);

Assert.AreEqual(expectedPolicy, resolved.Policy);
Assert.AreEqual(0, resolved.ClaimValues.Count);
logger.VerifyNoOtherCalls();
}

[TestMethod]
public void GetRolesForOperation_NullEntityNameThrows()
{
Expand Down
Loading
Loading