Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 1, 2025

Why make this change?

Closes #2787 - OpenAPI REST methods should show only available methods

The current OpenAPI behavior shows all REST methods even when they are not available. For example, read-only entities show all REST methods (GET, POST, PUT, PATCH, DELETE) in the OpenAPI document, which misrepresents the available options.

Related issues:

What is this change?

Permission-based filtering (OpenApiDocumentor.cs):

  • GetConfiguredRestOperations - Filters operations based on permissions superset across all roles
  • FilterFieldsByPermissions - Filters fields based on include/exclude settings across all roles
  • HasAnyAvailableOperations - Checks if entity has any permissions with actions defined
  • Entities with no available operations are completely omitted (no paths, tags, or schemas)

Role-specific OpenAPI endpoint (RestController.cs):

  • /openapi - Returns superset of all roles (existing behavior)
  • /openapi/{role} - Returns OpenAPI document filtered for a specific role
  • Case-insensitive role matching with URL-decoded parameter
  • Returns 404 if role not found or empty/whitespace

Request Body Strict Mode:

  • Request body schemas (_NoAutoPK, _NoPK, _sp_request) set AdditionalPropertiesAllowed based on RuntimeConfig.IsRequestBodyStrict
  • When request-body-strict: true (default): additionalProperties: false
  • When request-body-strict: false: additionalProperties: true
  • Response body schemas always allow additional properties

Wildcard (*) Behavior:

  • Tables/Views: * expands to all CRUD operations (Create→POST, Read→GET, Update→PUT/PATCH, Delete→DELETE)
  • Stored Procedures: * maps to Execute only; HTTP methods determined by entity.Rest.Methods

Permission → REST mapping:

Permission REST Methods
Read GET
Create POST
Update PUT, PATCH
Delete DELETE

Code Review Fixes:

  • Added empty/whitespace validation for role parameter
  • Replaced foreach loop with LINQ .Any() for role existence check
  • Simplified AdditionalPropertiesAllowed ternary expression
  • Simplified field filtering logic with ternary operator
  • Improved comment clarity for catch block behavior

How was this tested?

  • Integration Tests
  • Unit Tests

Tests split into focused files:

  • OperationFilteringTests.cs - Operation filtering tests (read-only, wildcard, no permissions, mixed roles)
  • FieldFilteringTests.cs - Field filtering tests (excluded fields, mixed role field permissions)
  • RoleIsolationTests.cs - Role isolation tests (anonymous vs superset, competing roles don't leak)
  • RequestBodyStrictTests.cs - Request-body-strict tests

Sample Request(s)

Superset OpenAPI (all roles combined):

GET /api/openapi

Role-specific OpenAPI:

GET /api/openapi/anonymous
GET /api/openapi/authenticated
GET /api/openapi/customrole

Returns 404 if role doesn't exist in any entity's permissions.

Config with read-only entity:

{
  "entities": {
    "Book": {
      "source": "dbo.books",
      "permissions": [
        { "role": "anonymous", "actions": ["read"] }
      ]
    }
  }
}

OpenAPI response shows only GET operations for /Book and /Book/id/{id} paths.

Config with excluded field:

{
  "entities": {
    "Book": {
      "source": "dbo.books",
      "permissions": [
        { 
          "role": "anonymous", 
          "actions": [{ "action": "*", "fields": { "exclude": ["secret_column"] } }]
        }
      ]
    }
  }
}

OpenAPI schema for Book entity will not include secret_column in its properties.

Config with competing roles:

{
  "entities": {
    "Book": {
      "source": "dbo.books",
      "permissions": [
        { "role": "reader", "actions": ["read"] },
        { "role": "writer", "actions": ["create"] }
      ]
    }
  }
}
  • /openapi shows GET (from reader) and POST (from writer) operations
  • /openapi/reader shows only GET operations
  • /openapi/writer shows only POST operations
  • /openapi/admin returns 404 (role not found)
Original prompt

This section details on the original issue you should resolve

<issue_title>[Enh]: OpenAPI REST methods should show only available methods</issue_title>
<issue_description>## What?

Show only the available REST methods in the OpenAPI document we produce.

Why?

The current OpenAPI behavior shows all REST methods even when they are not available.

For example, read-only entities show all the REST methods in the OpenAPI document.

How?

Available REST methods are limited in permissions for each role. In order to correctly show the available REST methods, we need to interrogate the current role and produce the role-specific OpenAPI that filters REST methods based on that role's permissions for each entity.

Notes

This does not require a change to the configuration. </issue_description>

<agent_instructions>Your goal is to satisfy this requirement with as little change or introduction of code as possible. Follow best practices and do not skip tests, but at the same time, do NOT introduce unnecessary code. Think and rethink if there is a simpler solution and ALWAYS choose the simplest and easiest and most terse that satisfies the requirement. You are a Principal Software Engineer at Microsoft on the Data API builder ENG team. Your code is intended to be an example to follow. Go. </agent_instructions>

Comments on the Issue (you are @copilot in this section)

@JerryNixon The current root OpenAPI shows the superset of the available permissions across all roles. If there are permissions for anonymous, authenticated, or custom roles, the root OpenAPI should show what is possible across all of them combined, not specific to any one role. However, if a permission like delete is not available to any role or a method is not available because there is no permission set (remembering that * represents create, read, update, and delete for tables and views while * represents execute for stored procedures) then those unavailable methods should be omitted from the root OpenAPI because it misrepresents the available options.

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Show only available REST methods in OpenAPI document Filter OpenAPI REST methods based on entity permissions Dec 1, 2025
Copilot AI requested a review from JerryNixon December 1, 2025 18:49
@JerryNixon
Copy link
Contributor

@copilot An entity with no possible methods should be completely omitted from the OpenAPI specification.

Copy link
Contributor Author

Copilot AI commented Dec 1, 2025

@copilot An entity with no possible methods should be completely omitted from the OpenAPI specification.

Implemented in commit 92da2fe. Entities with no available operations are now completely omitted from the OpenAPI specification - no paths, no tags, and no component schemas are generated for them. The HasAnyAvailableOperations helper and checks in BuildPaths, CreateDocument, and CreateComponentSchemas ensure this behavior.

@JerryNixon
Copy link
Contributor

@copilot Now take time to account for fields in the same way. Please note that fields are in the permissions / role / actions / fields property and can be designated in the configuration file with the * wildcard to indicate all and that there is both include and exclude which work together to create the available fields and that all of this is option in the configuration if the developer wants to simply expose all fields. That said, we cannot expose fields which are expressly excluded or not included.

Copy link
Contributor Author

Copilot AI commented Dec 1, 2025

@copilot Now take time to account for fields in the same way. Please note that fields are in the permissions / role / actions / fields property and can be designated in the configuration file with the...

Implemented in commit 48c76ae. Added FilterFieldsByPermissions method that:

  • Returns superset of available fields across all role/action permissions
  • Handles Include (null or * means all fields, otherwise explicit list)
  • Handles Exclude (removes specified fields, * excludes all)
  • If Fields is null on an action, all fields are available

The filtering is applied in CreateComponentSchemas right after getting exposed column names. Added tests for excluded fields and mixed role field permissions.

Copilot AI changed the title Filter OpenAPI REST methods based on entity permissions Filter OpenAPI REST methods and fields based on entity permissions Dec 1, 2025
@JerryNixon
Copy link
Contributor

@copilot Check that "none" and "anonymous" are not the same. As none should represent all roles together, anonymous is an explicit role like authenticated or some other custom role the user adds.

The next thing is to ensure that calling a single role results in what we want. I would like to see a test for this to ensure it works when, say, two competing roles are defined and the resulting openapi is correctly reprenseting the individual roles and not leaking information into the others.

Copy link
Contributor Author

Copilot AI commented Dec 1, 2025

@copilot Check that "none" and "anonymous" are not the same. As none should represent all roles together, anonymous is an explicit role like authenticated or some other custom role the user adds.

Th...

Implemented in commit 178910f:

  1. Role distinction: The filtering methods now have an optional role parameter. When null (no role specified), it returns the superset of all roles. When a specific role like "anonymous" is provided, it filters to only that role's permissions using case-insensitive comparison.

  2. Tests for competing roles:

    • AnonymousRole_IsDistinctFromSuperset - Verifies anonymous is a specific role, not the superset
    • CompetingRoles_DoNotLeakOperations - Reader role has only READ, writer role has only CREATE; superset has GET+POST but not PUT/PATCH/DELETE
    • CompetingRoles_DoNotLeakFields - Reader sees 'id', writer sees 'title'; superset correctly combines both without leaking

Copilot AI changed the title Filter OpenAPI REST methods and fields based on entity permissions Filter OpenAPI REST methods and fields based on entity permissions with role support Dec 1, 2025
@JerryNixon
Copy link
Contributor

@copilot Keep refactoring and simplifying. Remember we want to have good code coverage specifically for the lines you introduced in this PR. We also want to ensure existing tests that also might touch the code are updated or removed if required. Your goal is simplicity while still fulfilling the requirements. Every unit test should in this PR should test the code we introduced.

In addition, look at the routing to the OpenAPI endpoint. It is basically /OpenAPI which should have no role and shows the superset. But let's amend this to support /OpenAPI/{role} where this is how the user would indicate the unique role OpenAPI document they would like to see. This would take advantage of the code we just wrote. Make this case-insensitive and if the role is not found, it is a simple 404. There is no auth for the OpenAPI. The goal is not security but clarity so roles have the option to understand what they are allowed to do.

Copy link
Contributor Author

Copilot AI commented Dec 1, 2025

@copilot Keep refactoring and simplifying. Remember we want to have good code coverage specifically for the lines you introduced in this PR. We also want to ensure existing tests that also might touch...

Implemented in commit ae8166c:

  1. Refactored and simplified: Methods now use optional role parameter consistently
  2. Added /openapi/{role} routing:
    • /openapi returns superset of all roles
    • /openapi/{role} returns role-specific OpenAPI document
    • Case-insensitive role matching
    • URL-decoded role parameter for safety
    • Returns 404 if role not found in any entity's permissions
  3. Tests for competing roles: Added tests to verify operations and fields don't leak between roles

Copilot AI changed the title Filter OpenAPI REST methods and fields based on entity permissions with role support Filter OpenAPI REST methods and fields based on entity permissions with role-specific endpoint Dec 1, 2025
@JerryNixon JerryNixon added this to the Dec 2025 milestone Dec 1, 2025
@JerryNixon JerryNixon marked this pull request as ready for review December 2, 2025 18:18
Copilot AI review requested due to automatic review settings December 2, 2025 18:18
@JerryNixon JerryNixon changed the title Filter OpenAPI REST methods and fields based on entity permissions with role-specific endpoint Filter OpenAPI REST methods and fields based on role Dec 2, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements permission-based filtering for OpenAPI REST endpoint documentation. The changes allow the OpenAPI document to show only REST methods and fields that are available based on entity permissions, with support for both a superset view (all roles combined) and role-specific filtered views.

Key changes:

  • Permission-based operation filtering that maps CRUD permissions to HTTP methods (Read→GET, Create→POST, Update→PUT/PATCH, Delete→DELETE)
  • Role-specific OpenAPI endpoint (/openapi/{role}) in addition to the existing superset endpoint (/openapi)
  • Field-level filtering based on include/exclude configurations across role permissions
  • Request body strict mode support via additionalProperties schema setting

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/Service/Controllers/RestController.cs Adds role-specific OpenAPI endpoint handling at /openapi/{role} with URL decoding and case-insensitive role matching
src/Core/Services/OpenAPI/OpenApiDocumentor.cs Implements core filtering logic for operations and fields based on permissions, with role-specific document generation
src/Core/Services/OpenAPI/IOpenApiDocumentor.cs Adds interface method TryGetDocumentForRole for role-specific OpenAPI document retrieval
src/Service.Tests/OpenApiDocumentor/OperationFilteringTests.cs Tests for operation filtering including read-only, wildcard, and mixed-role scenarios
src/Service.Tests/OpenApiDocumentor/FieldFilteringTests.cs Tests for field-level filtering with exclude/include configurations
src/Service.Tests/OpenApiDocumentor/RoleIsolationTests.cs Tests ensuring role permissions don't leak between roles
src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs Tests for request-body-strict mode affecting additionalProperties setting
src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs Adds support for configuring request-body-strict setting in test bootstrap

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 147 to 150
catch
{
return false;
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block silently swallows all exceptions: This catch block (line 147) returns false for any exception without logging or providing any diagnostic information. This makes debugging difficult when document generation fails. Consider logging the exception or at least catching specific expected exceptions while allowing unexpected ones to bubble up.

Copilot uses AI. Check for mistakes.
Comment on lines 110 to 129
public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? document)
{
document = null;
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();

// Check if the role exists in any entity's permissions
bool roleExists = false;
foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities)
{
if (kvp.Value.Permissions?.Any(p => string.Equals(p.Role, role, StringComparison.OrdinalIgnoreCase)) == true)
{
roleExists = true;
break;
}
}

if (!roleExists)
{
return false;
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential edge case with empty role parameter: When role is an empty string (not null, but empty), the logic will pass the role existence check at line 119 if any entity has a permission with an empty role name. However, at line 239 in RestController.cs, the check !string.IsNullOrEmpty(role) would prevent calling TryGetDocumentForRole with an empty string. This inconsistency could lead to unexpected behavior. Consider adding a check for empty/whitespace strings in addition to null checks.

Copilot uses AI. Check for mistakes.
/// parameter's SystemType to JsonDataType.
/// </summary>
/// </summary>
/// <param name="fields">Collection of stored procedure parameter metadata.</param>
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate comment line: There is a duplicate closing comment /// </summary> on line 1356. The previous closing comment is already on line 1355, making this a syntax error or at minimum confusing documentation.

Copilot uses AI. Check for mistakes.
Comment on lines 903 to 967
private static HashSet<string> FilterFieldsByPermissions(Entity entity, HashSet<string> exposedColumnNames, string? role = null)
{
if (entity?.Permissions is null || entity.Permissions.Length == 0)
{
return exposedColumnNames;
}

HashSet<string> availableFields = new();

foreach (EntityPermission permission in entity.Permissions)
{
// Skip permissions for other roles if a specific role is requested
if (role is not null && !string.Equals(permission.Role, role, StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (permission.Actions is null)
{
continue;
}

foreach (EntityAction action in permission.Actions)
{
// If Fields is null, all fields are available for this action
if (action.Fields is null)
{
availableFields.UnionWith(exposedColumnNames);
continue;
}

// Determine included fields
HashSet<string> actionFields;
if (action.Fields.Include is null || action.Fields.Include.Contains("*"))
{
// Include is null or contains wildcard - start with all fields
actionFields = new HashSet<string>(exposedColumnNames);
}
else
{
// Only include explicitly listed fields that exist in exposed columns
actionFields = new HashSet<string>(action.Fields.Include.Where(f => exposedColumnNames.Contains(f)));
}

// Remove excluded fields
if (action.Fields.Exclude is not null && action.Fields.Exclude.Count > 0)
{
if (action.Fields.Exclude.Contains("*"))
{
// Exclude all - no fields available for this action
actionFields.Clear();
}
else
{
actionFields.ExceptWith(action.Fields.Exclude);
}
}

// Add to superset of available fields
availableFields.UnionWith(actionFields);
}
}

return availableFields;
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue with field filtering when no role matches: When FilterFieldsByPermissions is called with a specific role that exists in permissions but has no actions defined (or all actions have null Fields), it returns an empty HashSet<string> instead of the original exposedColumnNames. This could result in schemas with no properties at all. Consider returning exposedColumnNames when the role exists but has no field restrictions defined, similar to the behavior at line 907 when no permissions exist.

Copilot uses AI. Check for mistakes.
Comment on lines 116 to 124
bool roleExists = false;
foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities)
{
if (kvp.Value.Permissions?.Any(p => string.Equals(p.Role, role, StringComparison.OrdinalIgnoreCase)) == true)
{
roleExists = true;
break;
}
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
bool roleExists = false;
foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities)
{
if (kvp.Value.Permissions?.Any(p => string.Equals(p.Role, role, StringComparison.OrdinalIgnoreCase)) == true)
{
roleExists = true;
break;
}
}
bool roleExists = runtimeConfig.Entities
.Any(kvp => kvp.Value.Permissions?.Any(p => string.Equals(p.Role, role, StringComparison.OrdinalIgnoreCase)) == true);

Copilot uses AI. Check for mistakes.
Description = entityConfig?.Description,
// For request body schemas, set additionalProperties based on request-body-strict setting
// When strict is true, disallow extra fields; when false, allow them
AdditionalPropertiesAllowed = isRequestBodySchema ? !isRequestBodyStrict : true
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expression 'A ? B : true' can be simplified to '!A || B'.

Suggested change
AdditionalPropertiesAllowed = isRequestBodySchema ? !isRequestBodyStrict : true
AdditionalPropertiesAllowed = !isRequestBodySchema || !isRequestBodyStrict

Copilot uses AI. Check for mistakes.
Comment on lines 935 to 945
HashSet<string> actionFields;
if (action.Fields.Include is null || action.Fields.Include.Contains("*"))
{
// Include is null or contains wildcard - start with all fields
actionFields = new HashSet<string>(exposedColumnNames);
}
else
{
// Only include explicitly listed fields that exist in exposed columns
actionFields = new HashSet<string>(action.Fields.Include.Where(f => exposedColumnNames.Contains(f)));
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
HashSet<string> actionFields;
if (action.Fields.Include is null || action.Fields.Include.Contains("*"))
{
// Include is null or contains wildcard - start with all fields
actionFields = new HashSet<string>(exposedColumnNames);
}
else
{
// Only include explicitly listed fields that exist in exposed columns
actionFields = new HashSet<string>(action.Fields.Include.Where(f => exposedColumnNames.Contains(f)));
}
// Determine included fields using ternary operator for clarity
HashSet<string> actionFields =
(action.Fields.Include is null || action.Fields.Include.Contains("*"))
? new HashSet<string>(exposedColumnNames)
: new HashSet<string>(action.Fields.Include.Where(f => exposedColumnNames.Contains(f)));

Copilot uses AI. Check for mistakes.
@JerryNixon
Copy link
Contributor

@copilot Unclear if "action": "*" properly expands to all CRUD operations for tables vs just execute for SPs. Tests exist, but behavior should be validated.

Also

Performance Impact ⚠️ Generates separate OpenAPI document per role on-demand. With many roles/entities, this could be expensive. No caching mentioned. Suggestion: Lazy generation.

Copy link
Contributor Author

Copilot AI commented Dec 4, 2025

@copilot Unclear if "action": "*" properly expands to all CRUD operations for tables vs just execute for SPs. Tests exist, but behavior should be validated.

Also
...

Wildcard (*) Behavior:

  • Tables/Views: * correctly expands to all CRUD operations (Create→POST, Read→GET, Update→PUT/PATCH, Delete→DELETE). See GetConfiguredRestOperations at line 830 where EntityActionOperation.All enables all operations.
  • Stored Procedures: * maps to Execute only per AuthorizationResolver.GetAllOperationsForObjectType. HTTP methods for SPs are determined by entity.Rest.Methods configuration, not permissions.

This is validated by WildcardPermission_ShowsAllOperations test which uses EntityActionOperation.All and verifies all HTTP methods appear.

Performance: Role-specific documents are generated on-demand. For the /openapi superset endpoint, the document is cached in _openApiDocument. Lazy generation for /openapi/{role} follows a similar pattern to other DAB endpoints. Caching role-specific documents could be added if performance becomes an issue, but given that OpenAPI docs are typically requested infrequently (by developers, not production traffic), the current approach prioritizes simplicity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

[Enh]: OpenAPI REST methods should show only available methods

3 participants