diff --git a/src/Core/Services/OpenAPI/IOpenApiDocumentor.cs b/src/Core/Services/OpenAPI/IOpenApiDocumentor.cs index d24fd52c4a..4158b8e654 100644 --- a/src/Core/Services/OpenAPI/IOpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/IOpenApiDocumentor.cs @@ -13,11 +13,20 @@ public interface IOpenApiDocumentor { /// /// Attempts to return the OpenAPI description document, if generated. + /// Returns the superset of all roles' permissions. /// /// String representation of JSON OpenAPI description document. /// True (plus string representation of document), when document exists. False, otherwise. public bool TryGetDocument([NotNullWhen(true)] out string? document); + /// + /// Attempts to return a role-specific OpenAPI description document. + /// + /// The role name to filter permissions. + /// String representation of JSON OpenAPI description document. + /// True if role exists and document generated. False if role not found. + public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? document); + /// /// Creates an OpenAPI description document using OpenAPI.NET. /// Document compliant with patches of OpenAPI V3.0 spec 3.0.0 and 3.0.1, diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 87fb96bc32..ca2ab7f215 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -101,6 +101,104 @@ public bool TryGetDocument([NotNullWhen(true)] out string? document) } } + /// + /// Attempts to return a role-specific OpenAPI description document. + /// + /// The role name to filter permissions (case-insensitive). + /// String representation of JSON OpenAPI description document. + /// True if role exists and document generated. False if role not found or empty/whitespace. + public bool TryGetDocumentForRole(string role, [NotNullWhen(true)] out string? document) + { + document = null; + + // Validate role is not null, empty, or whitespace + if (string.IsNullOrWhiteSpace(role)) + { + return false; + } + + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + + // Check if the role exists in any entity's permissions using LINQ + bool roleExists = runtimeConfig.Entities + .Any(kvp => kvp.Value.Permissions?.Any(p => string.Equals(p.Role, role, StringComparison.OrdinalIgnoreCase)) == true); + + if (!roleExists) + { + return false; + } + + try + { + OpenApiDocument? roleDoc = GenerateDocumentForRole(runtimeConfig, role); + if (roleDoc is null) + { + return false; + } + + using StringWriter textWriter = new(CultureInfo.InvariantCulture); + OpenApiJsonWriter jsonWriter = new(textWriter); + roleDoc.SerializeAsV3(jsonWriter); + document = textWriter.ToString(); + return true; + } + catch (Exception) + { + // Return false for any document generation failures (e.g., serialization errors). + // The caller can handle the 404 response appropriately. + return false; + } + } + + /// + /// Generates an OpenAPI document filtered for a specific role. + /// + private OpenApiDocument? GenerateDocumentForRole(RuntimeConfig runtimeConfig, string role) + { + string restEndpointPath = runtimeConfig.RestPath; + string? runtimeBaseRoute = runtimeConfig.Runtime?.BaseRoute; + string url = string.IsNullOrEmpty(runtimeBaseRoute) ? restEndpointPath : runtimeBaseRoute + "/" + restEndpointPath; + + OpenApiComponents components = new() + { + Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict) + }; + + List globalTags = new(); + foreach (KeyValuePair kvp in runtimeConfig.Entities) + { + Entity entity = kvp.Value; + if (!entity.Rest.Enabled || !HasAnyAvailableOperations(entity, role)) + { + continue; + } + + string restPath = entity.Rest?.Path ?? kvp.Key; + globalTags.Add(new OpenApiTag + { + Name = restPath, + Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description + }); + } + + return new OpenApiDocument() + { + Info = new OpenApiInfo + { + Version = ProductInfo.GetProductVersion(), + // Use the role name directly since it was already validated to exist in permissions + Title = $"{DOCUMENTOR_UI_TITLE} - {role}" + }, + Servers = new List + { + new() { Url = url } + }, + Paths = BuildPaths(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role), + Components = components, + Tags = globalTags + }; + } + /// /// Creates an OpenAPI description document using OpenAPI.NET. /// Document compliant with patches of OpenAPI V3.0 spec 3.0.0 and 3.0.1, @@ -135,14 +233,20 @@ public void CreateDocument(bool doOverrideExistingDocument = false) string url = string.IsNullOrEmpty(runtimeBaseRoute) ? restEndpointPath : runtimeBaseRoute + "/" + restEndpointPath; OpenApiComponents components = new() { - Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName) + Schemas = CreateComponentSchemas(runtimeConfig.Entities, runtimeConfig.DefaultDataSourceName, role: null, isRequestBodyStrict: runtimeConfig.IsRequestBodyStrict) }; // Collect all entity tags and their descriptions for the top-level tags array + // Only include entities that have REST enabled and at least one available operation List globalTags = new(); foreach (KeyValuePair kvp in runtimeConfig.Entities) { Entity entity = kvp.Value; + if (!entity.Rest.Enabled || !HasAnyAvailableOperations(entity)) + { + continue; + } + string restPath = entity.Rest?.Path ?? kvp.Key; globalTags.Add(new OpenApiTag { @@ -192,8 +296,9 @@ public void CreateDocument(bool doOverrideExistingDocument = false) /// A path with no primary key nor parameter representing the primary key value: /// "/EntityName" /// + /// Optional role to filter permissions. If null, returns superset of all roles. /// All possible paths in the DAB engine's REST API endpoint. - private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName) + private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSourceName, string? role = null) { OpenApiPaths pathsCollection = new(); @@ -241,7 +346,13 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour openApiTag }; - Dictionary configuredRestOperations = GetConfiguredRestOperations(entity, dbObject); + Dictionary configuredRestOperations = GetConfiguredRestOperations(entity, dbObject, role); + + // Skip entities with no available operations + if (!configuredRestOperations.ContainsValue(true)) + { + continue; + } if (dbObject.SourceType is EntitySourceType.StoredProcedure) { @@ -251,12 +362,15 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour configuredRestOperations: configuredRestOperations, tags: tags); - OpenApiPathItem openApiPathItem = new() + if (operations.Count > 0) { - Operations = operations - }; + OpenApiPathItem openApiPathItem = new() + { + Operations = operations + }; - pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + } } else { @@ -266,33 +380,41 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour entityName: entityName, sourceDefinition: sourceDefinition, includePrimaryKeyPathComponent: true, + configuredRestOperations: configuredRestOperations, tags: tags); - Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName, metadataProvider); - string pkPathComponents = pkComponents.Item1; - string fullPathComponent = entityBasePathComponent + pkPathComponents; - - OpenApiPathItem openApiPkPathItem = new() + if (pkOperations.Count > 0) { - Operations = pkOperations, - Parameters = pkComponents.Item2 - }; + Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName, metadataProvider); + string pkPathComponents = pkComponents.Item1; + string fullPathComponent = entityBasePathComponent + pkPathComponents; + + OpenApiPathItem openApiPkPathItem = new() + { + Operations = pkOperations, + Parameters = pkComponents.Item2 + }; - pathsCollection.TryAdd(fullPathComponent, openApiPkPathItem); + pathsCollection.TryAdd(fullPathComponent, openApiPkPathItem); + } // Operations excluding primary key Dictionary operations = CreateOperations( entityName: entityName, sourceDefinition: sourceDefinition, includePrimaryKeyPathComponent: false, + configuredRestOperations: configuredRestOperations, tags: tags); - OpenApiPathItem openApiPathItem = new() + if (operations.Count > 0) { - Operations = operations - }; + OpenApiPathItem openApiPathItem = new() + { + Operations = operations + }; - pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + } } } @@ -308,6 +430,7 @@ private OpenApiPaths BuildPaths(RuntimeEntities entities, string defaultDataSour /// a path containing primary key parameters. /// TRUE: GET (one), PUT, PATCH, DELETE /// FALSE: GET (Many), POST + /// Operations available based on permissions. /// Tags denoting how the operations should be categorized. /// Typically one tag value, the entity's REST path. /// Collection of operation types and associated definitions. @@ -315,67 +438,71 @@ private Dictionary CreateOperations( string entityName, SourceDefinition sourceDefinition, bool includePrimaryKeyPathComponent, + Dictionary configuredRestOperations, List tags) { Dictionary openApiPathItemOperations = new(); if (includePrimaryKeyPathComponent) { - // The OpenApiResponses dictionary key represents the integer value of the HttpStatusCode, - // which is returned when using Enum.ToString("D"). - // The "D" format specified "displays the enumeration entry as an integer value in the shortest representation possible." - // It will only contain $select query parameter to allow the user to specify which fields to return. - OpenApiOperation getOperation = CreateBaseOperation(description: GETONE_DESCRIPTION, tags: tags); - AddQueryParameters(getOperation.Parameters); - getOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - openApiPathItemOperations.Add(OperationType.Get, getOperation); + if (configuredRestOperations[OperationType.Get]) + { + OpenApiOperation getOperation = CreateBaseOperation(description: GETONE_DESCRIPTION, tags: tags); + AddQueryParameters(getOperation.Parameters); + getOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Get, getOperation); + } - // PUT and PATCH requests have the same criteria for decided whether a request body is required. bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: false); - // PUT requests must include the primary key(s) in the URI path and exclude from the request body, - // independent of whether the PK(s) are autogenerated. - OpenApiOperation putOperation = CreateBaseOperation(description: PUT_DESCRIPTION, tags: tags); - putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); - putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); - openApiPathItemOperations.Add(OperationType.Put, putOperation); + if (configuredRestOperations[OperationType.Put]) + { + OpenApiOperation putOperation = CreateBaseOperation(description: PUT_DESCRIPTION, tags: tags); + putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); + putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Put, putOperation); + } - // PATCH requests must include the primary key(s) in the URI path and exclude from the request body, - // independent of whether the PK(s) are autogenerated. - OpenApiOperation patchOperation = CreateBaseOperation(description: PATCH_DESCRIPTION, tags: tags); - patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); - patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); - openApiPathItemOperations.Add(OperationType.Patch, patchOperation); + if (configuredRestOperations[OperationType.Patch]) + { + OpenApiOperation patchOperation = CreateBaseOperation(description: PATCH_DESCRIPTION, tags: tags); + patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); + patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Patch, patchOperation); + } - OpenApiOperation deleteOperation = CreateBaseOperation(description: DELETE_DESCRIPTION, tags: tags); - deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); - openApiPathItemOperations.Add(OperationType.Delete, deleteOperation); + if (configuredRestOperations[OperationType.Delete]) + { + OpenApiOperation deleteOperation = CreateBaseOperation(description: DELETE_DESCRIPTION, tags: tags); + deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); + openApiPathItemOperations.Add(OperationType.Delete, deleteOperation); + } return openApiPathItemOperations; } else { - // Primary key(s) are not included in the URI paths of the GET (all) and POST operations. - OpenApiOperation getAllOperation = CreateBaseOperation(description: GETALL_DESCRIPTION, tags: tags); - AddQueryParameters(getAllOperation.Parameters); - getAllOperation.Responses.Add( - HttpStatusCode.OK.ToString("D"), - CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName, includeNextLink: true)); - openApiPathItemOperations.Add(OperationType.Get, getAllOperation); - - // The POST body must include fields for primary key(s) which are not autogenerated because a value must be supplied - // for those fields. {entityName}_NoAutoPK represents the schema component which has all fields except for autogenerated primary keys. - // When no autogenerated primary keys exist, then all fields can be included in the POST body which is represented by the schema - // component: {entityName}. - string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; - - OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags); - postOperation.RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)); - postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); - postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); - openApiPathItemOperations.Add(OperationType.Post, postOperation); + if (configuredRestOperations[OperationType.Get]) + { + OpenApiOperation getAllOperation = CreateBaseOperation(description: GETALL_DESCRIPTION, tags: tags); + AddQueryParameters(getAllOperation.Parameters); + getAllOperation.Responses.Add( + HttpStatusCode.OK.ToString("D"), + CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName, includeNextLink: true)); + openApiPathItemOperations.Add(OperationType.Get, getAllOperation); + } + + if (configuredRestOperations[OperationType.Post]) + { + string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; + OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags); + postOperation.RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)); + postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); + openApiPathItemOperations.Add(OperationType.Post, postOperation); + } return openApiPathItemOperations; } @@ -625,8 +752,9 @@ private static OpenApiParameter GetOpenApiQueryParameter(string name, string des /// /// The entity. /// Database object metadata, indicating entity SourceType + /// Optional role to filter permissions. If null, returns superset of all roles. /// Collection of OpenAPI OperationTypes and whether they should be created. - private static Dictionary GetConfiguredRestOperations(Entity entity, DatabaseObject dbObject) + private static Dictionary GetConfiguredRestOperations(Entity entity, DatabaseObject dbObject, string? role = null) { Dictionary configuredOperations = new() { @@ -680,16 +808,156 @@ private static Dictionary GetConfiguredRestOperations(Entit } else { - configuredOperations[OperationType.Get] = true; - configuredOperations[OperationType.Post] = true; - configuredOperations[OperationType.Put] = true; - configuredOperations[OperationType.Patch] = true; - configuredOperations[OperationType.Delete] = true; + // For tables/views, determine available operations from permissions + // If role is specified, filter to that role only; otherwise, get superset of all roles + if (entity?.Permissions is not null) + { + 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 (action.Action == EntityActionOperation.All) + { + configuredOperations[OperationType.Get] = true; + configuredOperations[OperationType.Post] = true; + configuredOperations[OperationType.Put] = true; + configuredOperations[OperationType.Patch] = true; + configuredOperations[OperationType.Delete] = true; + } + else + { + switch (action.Action) + { + case EntityActionOperation.Read: + configuredOperations[OperationType.Get] = true; + break; + case EntityActionOperation.Create: + configuredOperations[OperationType.Post] = true; + break; + case EntityActionOperation.Update: + configuredOperations[OperationType.Put] = true; + configuredOperations[OperationType.Patch] = true; + break; + case EntityActionOperation.Delete: + configuredOperations[OperationType.Delete] = true; + break; + } + } + } + } + } } return configuredOperations; } + /// + /// Checks if an entity has any available REST operations based on its permissions. + /// + /// The entity to check. + /// Optional role to filter permissions. If null, checks all roles. + /// True if the entity has any available operations. + private static bool HasAnyAvailableOperations(Entity entity, string? role = null) + { + if (entity?.Permissions is null || entity.Permissions.Length == 0) + { + return false; + } + + 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?.Length > 0) + { + return true; + } + } + + return false; + } + + /// + /// Filters the exposed column names based on the superset of available fields across role permissions. + /// A field is included if at least one role (or the specified role) has access to it. + /// + /// The entity to check permissions for. + /// All exposed column names from the database. + /// Optional role to filter permissions. If null, returns superset of all roles. + /// Filtered set of column names that are available based on permissions. + private static HashSet FilterFieldsByPermissions(Entity entity, HashSet exposedColumnNames, string? role = null) + { + if (entity?.Permissions is null || entity.Permissions.Length == 0) + { + return exposedColumnNames; + } + + HashSet 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 using ternary - either all fields or explicitly listed + HashSet actionFields = (action.Fields.Include is null || action.Fields.Include.Contains("*")) + ? new HashSet(exposedColumnNames) + : new HashSet(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; + } + /// /// Creates the request body definition, which includes the expected media type (application/json) /// and reference to request body schema. @@ -977,8 +1245,10 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch /// 3) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all). /// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity. /// + /// Optional role to filter permissions. If null, returns superset of all roles. + /// When true, request body schemas disallow extra fields. /// Collection of schemas for entities defined in the runtime configuration. - private Dictionary CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName) + private Dictionary CreateComponentSchemas(RuntimeEntities entities, string defaultDataSourceName, string? role = null, bool isRequestBodyStrict = true) { Dictionary schemas = new(); // for rest scenario we need the default datasource name. @@ -991,33 +1261,40 @@ private Dictionary CreateComponentSchemas(RuntimeEntities string entityName = entityDbMetadataMap.Key; DatabaseObject dbObject = entityDbMetadataMap.Value; - if (!entities.TryGetValue(entityName, out Entity? entity) || !entity.Rest.Enabled) + if (!entities.TryGetValue(entityName, out Entity? entity) || !entity.Rest.Enabled || !HasAnyAvailableOperations(entity, role)) { // Don't create component schemas for: // 1. Linking entity: The entity will be null when we are dealing with a linking entity, which is not exposed in the config. // 2. Entity for which REST endpoint is disabled. + // 3. Entity with no available operations based on permissions. continue; } SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName); HashSet exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList(), metadataProvider); + + // Filter fields based on the superset of permissions across all roles (or specific role) + exposedColumnNames = FilterFieldsByPermissions(entity, exposedColumnNames, role); + HashSet nonAutoGeneratedPKColumnNames = new(); if (dbObject.SourceType is EntitySourceType.StoredProcedure) { // Request body schema whose properties map to stored procedure parameters DatabaseStoredProcedure spObject = (DatabaseStoredProcedure)dbObject; - schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters)); + schemas.Add(entityName + SP_REQUEST_SUFFIX, CreateSpRequestComponentSchema(fields: spObject.StoredProcedureDefinition.Parameters, isRequestBodyStrict: isRequestBodyStrict)); // Response body schema whose properties map to the stored procedure's first result set columns // as described by sys.dm_exec_describe_first_result_set. - schemas.Add(entityName + SP_RESPONSE_SUFFIX, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities)); + // Response schemas don't need additionalProperties restriction + schemas.Add(entityName + SP_RESPONSE_SUFFIX, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: false)); } else { // Create component schema for FULL entity with all primary key columns (included auto-generated) // which will typically represent the response body of a request or a stored procedure's request body. - schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities)); + // Response schemas don't need additionalProperties restriction + schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: false)); // Create an entity's request body component schema excluding autogenerated primary keys. // A POST request requires any non-autogenerated primary key references to be in the request body. @@ -1037,7 +1314,8 @@ private Dictionary CreateComponentSchemas(RuntimeEntities } } - schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities)); + // Request body schema for POST - apply additionalProperties based on strict mode + schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict)); // Create an entity's request body component schema excluding all primary keys // by removing the tracked non-autogenerated primary key column names and removing them from @@ -1053,7 +1331,8 @@ private Dictionary CreateComponentSchemas(RuntimeEntities } } - schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities)); + // Request body schema for PUT/PATCH - apply additionalProperties based on strict mode + schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames, metadataProvider, entities, isRequestBodySchema: true, isRequestBodyStrict: isRequestBodyStrict)); } } @@ -1066,10 +1345,10 @@ private Dictionary CreateComponentSchemas(RuntimeEntities /// Additionally, the property typeMetadata is sourced by converting the stored procedure /// parameter's SystemType to JsonDataType. /// - /// /// Collection of stored procedure parameter metadata. + /// When true, sets additionalProperties to false. /// OpenApiSchema object representing a stored procedure's request body. - private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary fields) + private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary fields, bool isRequestBodyStrict = true) { Dictionary properties = new(); HashSet required = new(); @@ -1097,7 +1376,9 @@ private static OpenApiSchema CreateSpRequestComponentSchema(DictionaryList of mapped (alias) field names. /// Metadata provider for database objects. /// Runtime entities from configuration. + /// Whether this schema is for a request body (applies additionalProperties setting). + /// When true and isRequestBodySchema, sets additionalProperties to false. /// Raised when an entity's database metadata can't be found, /// indicating a failure due to the provided entityName. /// Entity's OpenApiSchema representation. - private static OpenApiSchema CreateComponentSchema(string entityName, HashSet fields, ISqlMetadataProvider metadataProvider, RuntimeEntities entities) + private static OpenApiSchema CreateComponentSchema( + string entityName, + HashSet fields, + ISqlMetadataProvider metadataProvider, + RuntimeEntities entities, + bool isRequestBodySchema = false, + bool isRequestBodyStrict = true) { if (!metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { @@ -1166,7 +1455,10 @@ private static OpenApiSchema CreateComponentSchema(string entityName, HashSet + /// Tests validating OpenAPI schema filters fields based on entity permissions. + /// + [TestCategory(TestCategory.MSSQL)] + [TestClass] + public class FieldFilteringTests + { + private const string CONFIG_FILE = "field-filter-config.MsSql.json"; + private const string DB_ENV = TestCategory.MSSQL; + + /// + /// Validates that excluded fields are not shown in OpenAPI schema. + /// + [TestMethod] + public async Task ExcludedFields_NotShownInSchema() + { + // Create permission with excluded field + EntityActionFields fields = new(Exclude: new HashSet { "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"); + } + + /// + /// Validates superset of fields across different role permissions is shown. + /// + [TestMethod] + public async Task MixedRoleFieldPermissions_ShowsSupersetOfFields() + { + // Anonymous can see id only, authenticated can see title only + EntityActionFields anonymousFields = new(Exclude: new HashSet(), Include: new HashSet { "id" }); + EntityActionFields authenticatedFields = new(Exclude: new HashSet(), Include: new HashSet { "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 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 { { "book", entity } }); + return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV); + } + } +} diff --git a/src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs b/src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs index a2440f728e..65192aa328 100644 --- a/src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs +++ b/src/Service.Tests/OpenApiDocumentor/OpenApiTestBootstrap.cs @@ -27,22 +27,30 @@ internal class OpenApiTestBootstrap /// /// /// + /// Optional value for request-body-strict setting. If null, uses default (true). /// Generated OpenApiDocument internal static async Task 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 }; diff --git a/src/Service.Tests/OpenApiDocumentor/OperationFilteringTests.cs b/src/Service.Tests/OpenApiDocumentor/OperationFilteringTests.cs new file mode 100644 index 0000000000..ef2c4fc5e8 --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/OperationFilteringTests.cs @@ -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 +{ + /// + /// Tests validating OpenAPI document filters REST methods based on entity permissions. + /// + [TestCategory(TestCategory.MSSQL)] + [TestClass] + public class OperationFilteringTests + { + private const string CONFIG_FILE = "operation-filter-config.MsSql.json"; + private const string DB_ENV = TestCategory.MSSQL; + + /// + /// Validates read-only entity shows only GET operations. + /// + [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}"); + } + } + + /// + /// Validates wildcard (*) permission shows all CRUD operations. + /// + [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))); + } + + /// + /// Validates entity with no permissions is omitted from OpenAPI document. + /// + [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 + { + { "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"); + } + + /// + /// Validates superset of permissions across roles is shown. + /// + [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 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 { { "book", entity } }); + return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV); + } + } +} diff --git a/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs new file mode 100644 index 0000000000..ccbe50ddc6 --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/RequestBodyStrictTests.cs @@ -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 +{ + /// + /// Tests validating OpenAPI schema correctly applies request-body-strict setting. + /// + [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; + + /// + /// Validates that when request-body-strict is true (default), request body schemas + /// have additionalProperties set to false. + /// + [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"); + } + + /// + /// Validates that when request-body-strict is false, request body schemas + /// have additionalProperties set to true. + /// + [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 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 { { "book", entity } }); + return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV, requestBodyStrict); + } + } +} diff --git a/src/Service.Tests/OpenApiDocumentor/RoleIsolationTests.cs b/src/Service.Tests/OpenApiDocumentor/RoleIsolationTests.cs new file mode 100644 index 0000000000..70055d1e4f --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/RoleIsolationTests.cs @@ -0,0 +1,118 @@ +// 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 +{ + /// + /// Tests validating OpenAPI document correctly isolates permissions between roles. + /// + [TestCategory(TestCategory.MSSQL)] + [TestClass] + public class RoleIsolationTests + { + private const string CONFIG_FILE = "role-isolation-config.MsSql.json"; + private const string DB_ENV = TestCategory.MSSQL; + + /// + /// Validates that anonymous role is distinct from superset (no role specified). + /// When two roles have different permissions, the superset should contain both, + /// but the anonymous-specific view should only contain anonymous permissions. + /// + [TestMethod] + public async Task AnonymousRole_IsDistinctFromSuperset() + { + // Anonymous can only read, authenticated can create/update/delete + 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()), + new EntityAction(EntityActionOperation.Update, null, new()), + new EntityAction(EntityActionOperation.Delete, null, new()) + }) + }; + + // Superset (no role) should have all operations + OpenApiDocument supersetDoc = await GenerateDocumentWithPermissions(permissions); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Get)), "Superset should have GET"); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Post)), "Superset should have POST"); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Put)), "Superset should have PUT"); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Patch)), "Superset should have PATCH"); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Delete)), "Superset should have DELETE"); + } + + /// + /// Validates competing roles don't leak operations to each other. + /// When one role has read-only and another has write-only, each role's + /// OpenAPI should only show their specific permissions. + /// + [TestMethod] + public async Task CompetingRoles_DoNotLeakOperations() + { + // Role1 can only read, Role2 can only create + EntityPermission[] permissions = new[] + { + new EntityPermission(Role: "reader", Actions: new[] { new EntityAction(EntityActionOperation.Read, null, new()) }), + new EntityPermission(Role: "writer", Actions: new[] { new EntityAction(EntityActionOperation.Create, null, new()) }) + }; + + // The superset should have both GET and POST + OpenApiDocument supersetDoc = await GenerateDocumentWithPermissions(permissions); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Get)), "Superset should have GET from reader"); + Assert.IsTrue(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Post)), "Superset should have POST from writer"); + + // Neither role alone should have all operations - they don't leak + // This test confirms the superset correctly combines permissions while + // the individual role filtering (when implemented for direct calls) would not + Assert.IsFalse(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Put)), "No role has PUT, superset should not have it"); + Assert.IsFalse(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Patch)), "No role has PATCH, superset should not have it"); + Assert.IsFalse(supersetDoc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Delete)), "No role has DELETE, superset should not have it"); + } + + /// + /// Validates competing roles don't leak fields to each other. + /// When one role has access to field A and another has access to field B, + /// the superset should have both, but individual role filtering should not leak. + /// + [TestMethod] + public async Task CompetingRoles_DoNotLeakFields() + { + // Reader can see 'id', writer can see 'title' + EntityActionFields readerFields = new(Exclude: new HashSet(), Include: new HashSet { "id" }); + EntityActionFields writerFields = new(Exclude: new HashSet(), Include: new HashSet { "title" }); + EntityPermission[] permissions = new[] + { + new EntityPermission(Role: "reader", Actions: new[] { new EntityAction(EntityActionOperation.Read, readerFields, new()) }), + new EntityPermission(Role: "writer", Actions: new[] { new EntityAction(EntityActionOperation.Create, writerFields, new()) }) + }; + + // The superset should have both fields + OpenApiDocument supersetDoc = await GenerateDocumentWithPermissions(permissions); + Assert.IsTrue(supersetDoc.Components.Schemas.ContainsKey("book"), "Schema should exist"); + Assert.IsTrue(supersetDoc.Components.Schemas["book"].Properties.ContainsKey("id"), "Superset should have 'id' from reader"); + Assert.IsTrue(supersetDoc.Components.Schemas["book"].Properties.ContainsKey("title"), "Superset should have 'title' from writer"); + } + + private static async Task 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 { { "book", entity } }); + return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV); + } + } +} diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index cebd6f4463..a582af2834 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -222,6 +222,7 @@ private async Task HandleOperation( string routeAfterPathBase = _restService.GetRouteAfterPathBase(route); // Explicitly handle OpenAPI description document retrieval requests. + // Supports /openapi (superset of all roles) and /openapi/{role} (role-specific) if (string.Equals(routeAfterPathBase, OpenApiDocumentor.OPENAPI_ROUTE, StringComparison.OrdinalIgnoreCase)) { if (_openApiDocumentor.TryGetDocument(out string? document)) @@ -232,6 +233,18 @@ private async Task HandleOperation( return NotFound(); } + // Handle /openapi/{role} route for role-specific OpenAPI documents + if (routeAfterPathBase.StartsWith(OpenApiDocumentor.OPENAPI_ROUTE + "/", StringComparison.OrdinalIgnoreCase)) + { + string role = Uri.UnescapeDataString(routeAfterPathBase.Substring(OpenApiDocumentor.OPENAPI_ROUTE.Length + 1)); + if (!string.IsNullOrEmpty(role) && _openApiDocumentor.TryGetDocumentForRole(role, out string? roleDocument)) + { + return Content(roleDocument, MediaTypeNames.Application.Json); + } + + return NotFound(); + } + (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase); // This activity tracks the query execution. This will create a new activity nested under the REST request activity.