Skip to content

Commit 48c76ae

Browse files
CopilotJerryNixon
andcommitted
Add field filtering based on permissions in OpenAPI schema
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
1 parent 92da2fe commit 48c76ae

File tree

2 files changed

+114
-0
lines changed

2 files changed

+114
-0
lines changed

src/Core/Services/OpenAPI/OpenApiDocumentor.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,73 @@ private static bool HasAnyAvailableOperations(Entity entity)
776776
return false;
777777
}
778778

779+
/// <summary>
780+
/// Filters the exposed column names based on the superset of available fields across all role permissions.
781+
/// A field is included if at least one role has access to it (through include/exclude settings).
782+
/// </summary>
783+
/// <param name="entity">The entity to check permissions for.</param>
784+
/// <param name="exposedColumnNames">All exposed column names from the database.</param>
785+
/// <returns>Filtered set of column names that are available based on permissions.</returns>
786+
private static HashSet<string> FilterFieldsByPermissions(Entity entity, HashSet<string> exposedColumnNames)
787+
{
788+
if (entity?.Permissions is null || entity.Permissions.Length == 0)
789+
{
790+
return exposedColumnNames;
791+
}
792+
793+
HashSet<string> availableFields = new();
794+
795+
foreach (EntityPermission permission in entity.Permissions)
796+
{
797+
if (permission.Actions is null)
798+
{
799+
continue;
800+
}
801+
802+
foreach (EntityAction action in permission.Actions)
803+
{
804+
// If Fields is null, all fields are available for this action
805+
if (action.Fields is null)
806+
{
807+
availableFields.UnionWith(exposedColumnNames);
808+
continue;
809+
}
810+
811+
// Determine included fields
812+
HashSet<string> actionFields;
813+
if (action.Fields.Include is null || action.Fields.Include.Contains("*"))
814+
{
815+
// Include is null or contains wildcard - start with all fields
816+
actionFields = new HashSet<string>(exposedColumnNames);
817+
}
818+
else
819+
{
820+
// Only include explicitly listed fields that exist in exposed columns
821+
actionFields = new HashSet<string>(action.Fields.Include.Where(f => exposedColumnNames.Contains(f)));
822+
}
823+
824+
// Remove excluded fields
825+
if (action.Fields.Exclude is not null && action.Fields.Exclude.Count > 0)
826+
{
827+
if (action.Fields.Exclude.Contains("*"))
828+
{
829+
// Exclude all - no fields available for this action
830+
actionFields.Clear();
831+
}
832+
else
833+
{
834+
actionFields.ExceptWith(action.Fields.Exclude);
835+
}
836+
}
837+
838+
// Add to superset of available fields
839+
availableFields.UnionWith(actionFields);
840+
}
841+
}
842+
843+
return availableFields;
844+
}
845+
779846
/// <summary>
780847
/// Creates the request body definition, which includes the expected media type (application/json)
781848
/// and reference to request body schema.
@@ -1088,6 +1155,10 @@ private Dictionary<string, OpenApiSchema> CreateComponentSchemas(RuntimeEntities
10881155

10891156
SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName);
10901157
HashSet<string> exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList(), metadataProvider);
1158+
1159+
// Filter fields based on the superset of permissions across all roles
1160+
exposedColumnNames = FilterFieldsByPermissions(entity, exposedColumnNames);
1161+
10911162
HashSet<string> nonAutoGeneratedPKColumnNames = new();
10921163

10931164
if (dbObject.SourceType is EntitySourceType.StoredProcedure)

src/Service.Tests/OpenApiDocumentor/PermissionBasedOperationFilteringTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,49 @@ public async Task MixedRolePermissions_ShowsSupersetOfOperations()
116116
Assert.IsTrue(doc.Paths.Any(p => p.Value.Operations.ContainsKey(OperationType.Post)), "POST should exist from authenticated create");
117117
}
118118

119+
/// <summary>
120+
/// Validates that excluded fields are not shown in OpenAPI schema.
121+
/// </summary>
122+
[TestMethod]
123+
public async Task ExcludedFields_NotShownInSchema()
124+
{
125+
// Create permission with excluded field
126+
EntityActionFields fields = new(Exclude: new HashSet<string> { "publisher_id" }, Include: null);
127+
EntityPermission[] permissions = new[]
128+
{
129+
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.All, fields, new()) })
130+
};
131+
132+
OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);
133+
134+
// Check that the excluded field is not in the schema
135+
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Schema should exist for book entity");
136+
Assert.IsFalse(doc.Components.Schemas["book"].Properties.ContainsKey("publisher_id"), "Excluded field should not be in schema");
137+
}
138+
139+
/// <summary>
140+
/// Validates superset of fields across different role permissions is shown.
141+
/// </summary>
142+
[TestMethod]
143+
public async Task MixedRoleFieldPermissions_ShowsSupersetOfFields()
144+
{
145+
// Anonymous can see id only, authenticated can see title only
146+
EntityActionFields anonymousFields = new(Exclude: new HashSet<string>(), Include: new HashSet<string> { "id" });
147+
EntityActionFields authenticatedFields = new(Exclude: new HashSet<string>(), Include: new HashSet<string> { "title" });
148+
EntityPermission[] permissions = new[]
149+
{
150+
new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(EntityActionOperation.Read, anonymousFields, new()) }),
151+
new EntityPermission(Role: "authenticated", Actions: new[] { new EntityAction(EntityActionOperation.Read, authenticatedFields, new()) })
152+
};
153+
154+
OpenApiDocument doc = await GenerateDocumentWithPermissions(permissions);
155+
156+
// Should have both id (from anonymous) and title (from authenticated) - superset of fields
157+
Assert.IsTrue(doc.Components.Schemas.ContainsKey("book"), "Schema should exist for book entity");
158+
Assert.IsTrue(doc.Components.Schemas["book"].Properties.ContainsKey("id"), "Field 'id' should be in schema from anonymous role");
159+
Assert.IsTrue(doc.Components.Schemas["book"].Properties.ContainsKey("title"), "Field 'title' should be in schema from authenticated role");
160+
}
161+
119162
private static async Task<OpenApiDocument> GenerateDocumentWithPermissions(EntityPermission[] permissions)
120163
{
121164
Entity entity = new(

0 commit comments

Comments
 (0)