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
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
458 changes: 375 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