Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
9 changes: 9 additions & 0 deletions src/Core/Services/OpenAPI/IOpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ public interface IOpenApiDocumentor
{
/// <summary>
/// Attempts to return the OpenAPI description document, if generated.
/// Returns the superset of all roles' permissions.
/// </summary>
/// <param name="document">String representation of JSON OpenAPI description document.</param>
/// <returns>True (plus string representation of document), when document exists. False, otherwise.</returns>
public bool TryGetDocument([NotNullWhen(true)] out string? document);

/// <summary>
/// Attempts to return a role-specific OpenAPI description document.
/// </summary>
/// <param name="role">The role name to filter permissions.</param>
/// <param name="document">String representation of JSON OpenAPI description document.</param>
/// <returns>True if role exists and document generated. False if role not found.</returns>
public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? document);

/// <summary>
/// Creates an OpenAPI description document using OpenAPI.NET.
/// Document compliant with patches of OpenAPI V3.0 spec 3.0.0 and 3.0.1,
Expand Down
466 changes: 383 additions & 83 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions src/Service.Tests/OpenApiDocumentor/FieldFilteringTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Microsoft.OpenApi.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
{
/// <summary>
/// Tests validating OpenAPI schema filters fields based on entity permissions.
/// </summary>
[TestCategory(TestCategory.MSSQL)]
[TestClass]
public class FieldFilteringTests
{
private const string CONFIG_FILE = "field-filter-config.MsSql.json";
private const string DB_ENV = TestCategory.MSSQL;

/// <summary>
/// Validates that excluded fields are not shown in OpenAPI schema.
/// </summary>
[TestMethod]
public async Task ExcludedFields_NotShownInSchema()
{
// Create permission with excluded field
EntityActionFields fields = new(Exclude: new HashSet<string> { "publisher_id" }, Include: null);
EntityPermission[] permissions = new[]
{
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.All, fields, new()) })
};

OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);

// Check that the excluded field is not in the schema
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Schema should exist for book entity");
Assert.IsFalse(doc.Components.Schemas["book"].Properties.ContainsKey("publisher_id"), "Excluded field should not be in schema");
}

/// <summary>
/// Validates superset of fields across different role permissions is shown.
/// </summary>
[TestMethod]
public async Task MixedRoleFieldPermissions_ShowsSupersetOfFields()
{
// Anonymous can see id only, authenticated can see title only
EntityActionFields anonymousFields = new(Exclude: new HashSet<string>(), Include: new HashSet<string> { "id" });
EntityActionFields authenticatedFields = new(Exclude: new HashSet<string>(), Include: new HashSet<string> { "title" });
EntityPermission[] permissions = new[]
{
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.Read, anonymousFields, new()) }),
new EntityPermission(Role: "authenticated", Actions: new[] { new EntityAction(EntityActionOperation.Read, authenticatedFields, new()) })
};

OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);

// Should have both id (from anonymous) and title (from authenticated) - superset of fields
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Schema should exist for book entity");
Assert.IsTrue(doc.Components.Schemas["book"].Properties.ContainsKey("id"), "Field 'id' should be in schema from anonymous role");
Assert.IsTrue(doc.Components.Schemas["book"].Properties.ContainsKey("title"), "Field 'title' should be in schema from authenticated role");
}

private static async Task<OpenApiDocument> GenerateDocumentWithPermissions(EntityPermission[] permissions)
{
Entity entity = new(
Source: new("books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(null, null, false),
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: permissions,
Mappings: null,
Relationships: null);

RuntimeEntities entities = new(new Dictionary<string, Entity> { { "book", entity } });
return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV);
}
}
}
12 changes: 10 additions & 2 deletions src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,30 @@ internal class OpenApiTestBootstrap
/// <param name="runtimeEntities"></param>
/// <param name="configFileName"></param>
/// <param name="databaseEnvironment"></param>
/// <param name="requestBodyStrict">Optional value for request-body-strict setting. If null, uses default (true).</param>
/// <returns>Generated OpenApiDocument</returns>
internal static async Task<OpenApiDocument> GenerateOpenApiDocumentAsync(
RuntimeEntities runtimeEntities,
string configFileName,
string databaseEnvironment)
string databaseEnvironment,
bool? requestBodyStrict = null)
{
TestHelper.SetupDatabaseEnvironment(databaseEnvironment);
FileSystem fileSystem = new();
FileSystemRuntimeConfigLoader loader = new(fileSystem);
loader.TryLoadKnownConfig(out RuntimeConfig config);

// Create Rest options with the specified request-body-strict setting
RestRuntimeOptions restOptions = requestBodyStrict.HasValue
? config.Runtime?.Rest with { RequestBodyStrict = requestBodyStrict.Value } ?? new RestRuntimeOptions(RequestBodyStrict: requestBodyStrict.Value)
: config.Runtime?.Rest ?? new RestRuntimeOptions();

RuntimeConfig configWithCustomHostMode = config with
{
Runtime = config.Runtime with
{
Host = config.Runtime?.Host with { Mode = HostMode.Production }
Host = config.Runtime?.Host with { Mode = HostMode.Production },
Rest = restOptions
},
Entities = runtimeEntities
};
Expand Down
134 changes: 134 additions & 0 deletions src/Service.Tests/OpenApiDocumentor/OperationFilteringTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Microsoft.OpenApi.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
{
/// <summary>
/// Tests validating OpenAPI document filters REST methods based on entity permissions.
/// </summary>
[TestCategory(TestCategory.MSSQL)]
[TestClass]
public class OperationFilteringTests
{
private const string CONFIG_FILE = "operation-filter-config.MsSql.json";
private const string DB_ENV = TestCategory.MSSQL;

/// <summary>
/// Validates read-only entity shows only GET operations.
/// </summary>
[TestMethod]
public async Task ReadOnlyEntity_ShowsOnlyGetOperations()
{
EntityPermission[] permissions = new[]
{
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.Read, null, new()) })
};

OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);

foreach (var path in doc.Paths)
{
Assert.IsTrue(path.Value.Operations.ContainsKey(OperationType.Get), $"GET missing at {path.Key}");
Assert.IsFalse(path.Value.Operations.ContainsKey(OperationType.Post), $"POST should not exist at {path.Key}");
Assert.IsFalse(path.Value.Operations.ContainsKey(OperationType.Put), $"PUT should not exist at {path.Key}");
Assert.IsFalse(path.Value.Operations.ContainsKey(OperationType.Patch), $"PATCH should not exist at {path.Key}");
Assert.IsFalse(path.Value.Operations.ContainsKey(OperationType.Delete), $"DELETE should not exist at {path.Key}");
}
}

/// <summary>
/// Validates wildcard (*) permission shows all CRUD operations.
/// </summary>
[TestMethod]
public async Task WildcardPermission_ShowsAllOperations()
{
OpenApiDocument doc = await GenerateDocumentWithPermissions(OpenApiTestBootstrap.CreateBasicPermissions());

Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Get)));
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Post)));
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Put)));
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Patch)));
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Delete)));
}

/// <summary>
/// Validates entity with no permissions is omitted from OpenAPI document.
/// </summary>
[TestMethod]
public async Task EntityWithNoPermissions_IsOmittedFromDocument()
{
// Entity with no permissions
Entity entityNoPerms = new(
Source: new("books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(null, null, false),
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: [],
Mappings: null,
Relationships: null);

// Entity with permissions for reference
Entity entityWithPerms = new(
Source: new("publishers", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(null, null, false),
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
Mappings: null,
Relationships: null);

RuntimeEntities entities = new(new Dictionary<string, Entity>
{
{ "book", entityNoPerms },
{ "publisher", entityWithPerms }
});

OpenApiDocument doc = await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV);

Assert.IsFalse(doc.Paths.Keys.Any(k => k.Contains("book")), "Entity with no permissions should not have paths");
Assert.IsFalse(doc.Tags.Any(t => t.Name == "book"), "Entity with no permissions should not have tag");
Assert.IsTrue(doc.Paths.Keys.Any(k => k.Contains("publisher")), "Entity with permissions should have paths");
}

/// <summary>
/// Validates superset of permissions across roles is shown.
/// </summary>
[TestMethod]
public async Task MixedRolePermissions_ShowsSupersetOfOperations()
{
EntityPermission[] permissions = new[]
{
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.Read, null, new()) }),
new EntityPermission(Role: "authenticated", Actions: new[] { new EntityAction(EntityActionOperation.Create, null, new()) })
};

OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);

// Should have both GET (from anonymous read) and POST (from authenticated create)
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Get)), "GET should exist from anonymous read");
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Post)), "POST should exist from authenticated create");
}

private static async Task<OpenApiDocument> GenerateDocumentWithPermissions(EntityPermission[] permissions)
{
Entity entity = new(
Source: new("books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(null, null, false),
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: permissions,
Mappings: null,
Relationships: null);

RuntimeEntities entities = new(new Dictionary<string, Entity> { { "book", entity } });
return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV);
}
}
}
79 changes: 79 additions & 0 deletions src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Microsoft.OpenApi.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
{
/// <summary>
/// Tests validating OpenAPI schema correctly applies request-body-strict setting.
/// </summary>
[TestCategory(TestCategory.MSSQL)]
[TestClass]
public class RequestBodyStrictTests
{
private const string CONFIG_FILE = "request-body-strict-config.MsSql.json";
private const string DB_ENV = TestCategory.MSSQL;

/// <summary>
/// Validates that when request-body-strict is true (default), request body schemas
/// have additionalProperties set to false.
/// </summary>
[TestMethod]
public async Task RequestBodyStrict_True_DisallowsExtraFields()
{
OpenApiDocument doc = await GenerateDocumentWithPermissions(
OpenApiTestBootstrap.CreateBasicPermissions(),
requestBodyStrict: true);

// Request body schemas should have additionalProperties = false
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should exist");
Assert.IsFalse(doc.Components.Schemas["book_NoAutoPK"].AdditionalPropertiesAllowed, "POST request body should not allow extra fields in strict mode");

Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should exist");
Assert.IsFalse(doc.Components.Schemas["book_NoPK"].AdditionalPropertiesAllowed, "PUT/PATCH request body should not allow extra fields in strict mode");

// Response body schema should allow extra fields (not a request body)
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Response body schema should exist");
Assert.IsTrue(doc.Components.Schemas["book"].AdditionalPropertiesAllowed, "Response body should allow extra fields");
}

/// <summary>
/// Validates that when request-body-strict is false, request body schemas
/// have additionalProperties set to true.
/// </summary>
[TestMethod]
public async Task RequestBodyStrict_False_AllowsExtraFields()
{
OpenApiDocument doc = await GenerateDocumentWithPermissions(
OpenApiTestBootstrap.CreateBasicPermissions(),
requestBodyStrict: false);

// Request body schemas should have additionalProperties = true
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoAutoPK"), "POST request body schema should exist");
Assert.IsTrue(doc.Components.Schemas["book_NoAutoPK"].AdditionalPropertiesAllowed, "POST request body should allow extra fields in non-strict mode");

Assert.IsTrue(doc.Components.Schemas.ContainsKey("book_NoPK"), "PUT/PATCH request body schema should exist");
Assert.IsTrue(doc.Components.Schemas["book_NoPK"].AdditionalPropertiesAllowed, "PUT/PATCH request body should allow extra fields in non-strict mode");
}

private static async Task<OpenApiDocument> GenerateDocumentWithPermissions(EntityPermission[] permissions, bool? requestBodyStrict = null)
{
Entity entity = new(
Source: new("books", EntitySourceType.Table, null, null),
Fields: null,
GraphQL: new(null, null, false),
Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
Permissions: permissions,
Mappings: null,
Relationships: null);

RuntimeEntities entities = new(new Dictionary<string, Entity> { { "book", entity } });
return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV, requestBodyStrict);
}
}
}
Loading