Skip to content

Entity config import/export: CRUD permissions silently unenforced, property data dropped, no versioning #5404

Description

@ihouvet

Summary

The entity-configuration distribution pipeline (.shaconfig export/import) has three defects. One is security-relevant: entity CRUD permissions configured in a source environment are silently not enforced after import. The other two cause data loss and unversioned overwrites.

All three were found by source review against releases/0.43 @ bff870d17. There is currently no test coverage for entity-config distribution — shesha-core/test/Shesha.Tests/ConfigurationItems/FormConfiguration_Tests.cs is the only distribution test in the repo, which is consistent with these defects going unnoticed.


Defect 1 — Imported entity CRUD permissions are silently not enforced (security-relevant)

Severity: High. An entity locked down to a specific permission in the source environment becomes accessible to any authenticated user in the target environment after import, with no error or warning.

EntityConfigExport writes the four CRUD action permissions with object type Shesha.Entity.Action:

// shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/EntityConfigExport.cs:83-87
Permission       = await _permissionedObjectManager.GetOrDefaultAsync($"{ns}.{cls}",        ShaPermissionedObjectsTypes.Entity),
PermissionGet    = await _permissionedObjectManager.GetOrDefaultAsync($"{ns}.{cls}@Get",    ShaPermissionedObjectsTypes.EntityAction),
PermissionCreate = await _permissionedObjectManager.GetOrDefaultAsync($"{ns}.{cls}@Create", ShaPermissionedObjectsTypes.EntityAction),
PermissionUpdate = await _permissionedObjectManager.GetOrDefaultAsync($"{ns}.{cls}@Update", ShaPermissionedObjectsTypes.EntityAction),
PermissionDelete = await _permissionedObjectManager.GetOrDefaultAsync($"{ns}.{cls}@Delete", ShaPermissionedObjectsTypes.EntityAction),

The entity-config UI save path agrees — Shesha.Entity for the parent, Shesha.Entity.Action for the four actions:

// shesha-core/src/Shesha.Framework/DynamicEntities/ModelConfigurationManager.cs:325-349
input.Permission.Type       = ShaPermissionedObjectsTypes.Entity;        // parent
input.PermissionGet.Type    = ShaPermissionedObjectsTypes.EntityAction;
input.PermissionCreate.Type = ShaPermissionedObjectsTypes.EntityAction;
input.PermissionUpdate.Type = ShaPermissionedObjectsTypes.EntityAction;
input.PermissionDelete.Type = ShaPermissionedObjectsTypes.EntityAction;

But the importer overwrites all five with Shesha.Entity:

// shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/EntityConfigImport.cs:185-209
if (item.Permission != null)
{
    // fix Type for old configurations
    item.Permission.Type = ShaPermissionedObjectsTypes.Entity;          // correct
    await _permissionedObjectManager.SetAsync(item.Permission);
}
if (item.PermissionGet != null)
{
    item.PermissionGet.Type = ShaPermissionedObjectsTypes.Entity;       // WRONG -> should be EntityAction
    await _permissionedObjectManager.SetAsync(item.PermissionGet);
}
// ... same wrong assignment for PermissionCreate / PermissionUpdate / PermissionDelete

Why it silently fails

  1. PermissionedObjectManager.SetAsync matches an existing row on both Object and Type:

    // PermissionedObjectManager.cs:391-393
    .Where(x => x.Object == permissionedObject.Object && x.Type == permissionedObject.Type)

    With the wrong Type, no existing row matches, so a new row is inserted under Shesha.Entity instead of updating the Shesha.Entity.Action row.

  2. At request time, EntityCrudAuthorizationHelper looks up Shesha.Entity.Action:

    // shesha-core/src/Shesha.Application/Authorization/EntityCrudAuthorizationHelper.cs:82
    await _objectPermissionChecker.AuthorizeAsync(false, config.FullClassName, method,
        ShaPermissionedObjectsTypes.EntityAction, AbpSession.UserId.HasValue, defaultAccess);

    PermissionedObjectManager.GetInternalAsync filters on x.Type == objectType, misses the imported row, and returns a synthesised default with Access = Inherited. ObjectPermissionChecker then substitutes SecuritySettings.DefaultEndpointAccess (AnyAuthenticated by default) — so the restriction evaporates.

The // fix Type for old configurations comment on line 188 was plainly intended for item.Permission alone and was copy-pasted down to the four action blocks. Introduced in 8c56003c6 ("Fix Permission objects initialization") and never corrected.

Fix: use ShaPermissionedObjectsTypes.EntityAction for PermissionGet / PermissionCreate / PermissionUpdate / PermissionDelete; leave Permission as ShaPermissionedObjectsTypes.Entity. Consider also a data-repair migration for rows already written with the wrong type by a previous import (match Object LIKE '%@Get' etc. with Type = 'Shesha.Entity'), de-duplicating against any correctly-typed row that already exists.


Defect 2 — Property data is silently dropped on import

EntityConfigExport.MapPropertyAsync writes Source, SortOrder, nested Properties, and ItemsType:

// EntityConfigExport.cs:104-144
property.Source    = src.Source;
property.SortOrder = src.SortOrder;
property.ItemsType = await MapPropertyAsync(src.ItemsType);
foreach (var childProp in src.Properties)
    property.Properties.Add(await MapPropertyAsync(childProp));

EntityConfigImport.MapPropertiesAsync (EntityConfigImport.cs:215-252) never reads any of those four. Consequences:

  • Nested Properties lost — child properties of complex / JSON-entity types do not survive import.
  • ItemsType lost — the element type of list/array properties is dropped.
  • SortOrder lost — property ordering in the target environment is arbitrary.
  • Source lostMetadataSourceType (code vs. user-defined) is not carried over.
  • Removals do not propagate — the loop only inserts/updates; properties absent from the package are left in place in the target. (Contrast ModelConfigurationManager.cs:318-323, which does delete properties absent from the input.)

All four fields exist on the DTO (DistributedEntityConfigProperty.cs:61,66,71,76), so this is purely a gap in the import mapper.

Fix: map Source, SortOrder, ItemsType, and recurse into Properties (mirroring the exporter's recursion); delete target properties not present in the package.


Defect 3 — Import overwrites the live version instead of creating a new one

EntityConfigImport.ImportEntityConfigAsync updates the current IsLast record in place:

// EntityConfigImport.cs:69-89
var dbItem = await _entityConfigRepo.FirstOrDefaultAsync(x => ... && x.IsLast);
if (dbItem != null)
{
    // ToDo: Tempjrary update the current version.
    // Need to update the rest of the other code to work with versioning EntityConfigs first
    await MapEntityConfigAsync(item, dbItem, context);
    await _entityConfigRepo.UpdateAsync(dbItem);
    ...
}

The proper versioning path (cancel/retire the existing version, CreateNewVersionAsync, set ParentVersion) is present but commented out at EntityConfigImport.cs:90-127. FormConfigurationImport does version correctly, and FormConfiguration_Tests.When_Import_Existing_FormAsync asserts exactly that behaviour — so entity configs are inconsistent with the rest of the framework: no rollback, no audit trail of what an import changed, and ConfigurationItemViewMode/status filtering cannot see prior states.

Fix: enable the versioning path so entity-config import behaves like form-config import. This is the largest of the three changes and may reasonably be split into its own PR — see the note in the implementation prompt.


Acceptance criteria

  • A new test file shesha-core/test/Shesha.Tests/ConfigurationItems/EntityConfiguration_Tests.cs exists, modelled on FormConfiguration_Tests.cs (same SheshaNhTestBase base class, same MemoryRepository / IMemoryDatabaseProvider substitute pattern, same TestImportContext src→dst export-then-import shape).
  • The test suite covers, at minimum:
    • Export of an EntityConfig returns the expected scalar fields (name, module, class name, namespace, label, description).
    • Defect 1: an entity whose Get/Create/Update/Delete permissions are set to RequiresPermissions with a named permission in src is, after import into dst, retrievable via GetOrDefaultAsync(..., ShaPermissionedObjectsTypes.EntityAction) with that same access level and permission list — i.e. the exact lookup EntityCrudAuthorizationHelper performs at runtime. Asserting on the round-tripped DTO alone is not sufficient; the assertion must go through the EntityAction type lookup.
    • Defect 1: no duplicate PermissionedObject row is created under type Shesha.Entity for the @Get/@Create/@Update/@Delete object names.
    • Defect 2: Source, SortOrder, ItemsType, and nested Properties (at least two levels deep) survive the round trip.
    • Defect 2: a property present in dst but absent from the imported package is removed.
    • Defect 3: importing over an existing live EntityConfig creates a new version — VersionNo incremented, ParentVersion set to the previous record, previous record marked Retired — mirroring When_Import_Existing_FormAsync.
  • Each test fails against current main for the reason the defect predicts (recorded in the PR description), and passes after the fix.
  • No regression in FormConfiguration_Tests or the rest of Shesha.Tests.
  • If a data-repair migration is included for Defect 1, it is idempotent and does not create duplicate rows where a correctly-typed row already exists.

Implementation prompt (for a coding agent)

Paste the block below into a coding agent working in a clone of shesha-io/shesha-framework.

You are working in the shesha-io/shesha-framework repository (branch: releases/0.43 or later).

Fix three defects in the EntityConfig configuration-item distribution (export/import) pipeline.
Work TEST-FIRST: write failing tests, prove they fail for the right reason, then fix, then prove they pass.

=== BACKGROUND: read these files before writing anything ===

  shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/EntityConfigExport.cs
  shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/EntityConfigImport.cs
  shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/Dto/DistributedEntityConfig.cs
  shesha-core/src/Shesha.Framework/DynamicEntities/Distribution/Dto/DistributedEntityConfigProperty.cs
  shesha-core/src/Shesha.Framework/DynamicEntities/ModelConfigurationManager.cs      (the correct UI save path -- reference for permission types and property deletion)
  shesha-core/src/Shesha.Framework/Permissions/PermissionedObjectManager.cs          (SetAsync / GetInternalAsync / GetOrDefaultAsync)
  shesha-core/src/Shesha.Framework/Permissions/ShaPermissionedObjectsTypes.cs
  shesha-core/src/Shesha.Application/Authorization/EntityCrudAuthorizationHelper.cs  (the runtime lookup that must succeed)
  shesha-core/test/Shesha.Tests/ConfigurationItems/FormConfiguration_Tests.cs        (the test pattern to copy)

The three defects:

  D1 (security). EntityConfigImport.cs lines ~185-209 set Type = ShaPermissionedObjectsTypes.Entity
     on ALL FIVE permission DTOs. Only the parent (item.Permission) is correct. PermissionGet,
     PermissionCreate, PermissionUpdate and PermissionDelete must be ShaPermissionedObjectsTypes.EntityAction
     -- matching EntityConfigExport.cs:84-87 and ModelConfigurationManager.cs:332-347.
     Because PermissionedObjectManager.SetAsync matches on (Object, Type), the wrong Type inserts a
     duplicate row instead of updating, and EntityCrudAuthorizationHelper (which looks up EntityAction)
     misses it and falls back to SecuritySettings.DefaultEndpointAccess. Restrictions silently vanish.

  D2 (data loss). EntityConfigImport.MapPropertiesAsync (lines ~215-252) ignores four fields the
     exporter writes: Source, SortOrder, ItemsType, and nested Properties. It also never deletes
     properties absent from the package. Mirror the exporter's recursion and follow
     ModelConfigurationManager.cs:318-323 for the deletion semantics.

  D3 (versioning). EntityConfigImport.ImportEntityConfigAsync (lines ~69-89) overwrites the current
     IsLast record in place. The correct versioning path is commented out at lines ~90-127. Enable it
     so entity-config import behaves like FormConfigurationImport: cancel/retire the existing version,
     CreateNewVersionAsync, set ParentVersion, apply the context's ImportStatusAs.

=== STEP 1: write the tests FIRST, and confirm they FAIL ===

Create shesha-core/test/Shesha.Tests/ConfigurationItems/EntityConfiguration_Tests.cs.

Model it closely on FormConfiguration_Tests.cs:
  - class EntityConfiguration_Tests : SheshaNhTestBase
  - a private GetMemoryDbProvider() returning an NSubstitute Substitute.For<IMemoryDatabaseProvider>()
    whose Database returns a fresh MemoryDatabase()
  - a private TestImportContext class holding MemoryRepository instances for EntityConfig,
    EntityProperty, Module, FrontEndApp and ConfigurationItem, plus GetOrCreateModuleAsync and an
    AddEntityConfigAsync helper
  - the src-context -> export -> dst-context -> import shape used by When_Import_Missing_FormAsync
    and When_Import_Existing_FormAsync

EntityConfigExport ctor takes (IRepository<EntityConfig,Guid>, IRepository<EntityProperty,Guid>,
IPermissionedObjectManager). EntityConfigImport ctor takes (IRepository<Module,Guid>,
IRepository<FrontEndApp,Guid>, IRepository<EntityConfig,Guid>, IRepository<EntityProperty,Guid>,
IPermissionedObjectManager, IEntityConfigManager, IUnitOfWorkManager, IModelConfigsCacheHolder).
Resolve<T>() the framework services as FormConfiguration_Tests does; substitute only what you must.

Write these tests:

  1. ShouldExport_TestAsync
     Export an EntityConfig; assert Name, ModuleName, ClassName, Namespace, Label, Description.

  2. When_Import_EntityPermissions_ShouldBeEnforceable_TestAsync            [D1 -- the important one]
     In src, set the four CRUD permissions on the entity via IPermissionedObjectManager.SetAsync
     with Type = ShaPermissionedObjectsTypes.EntityAction, Access = RequiresPermissions and
     Permissions = ["test:permission"] for objects "<ns>.<cls>@Get", "@Create", "@Update", "@Delete".
     Export, import into dst, then for EACH of the four actions assert:
         var p = await dstPermissionedObjectManager.GetOrDefaultAsync(
                     $"{ns}.{cls}@{action}", ShaPermissionedObjectsTypes.EntityAction);
         p.ActualAccess.ShouldBe(RefListPermissionedAccess.RequiresPermissions);
         p.ActualPermissions.ShouldContain("test:permission");
     This is deliberately the same lookup EntityCrudAuthorizationHelper.cs:82 performs. Do NOT assert
     on the exported/round-tripped DTO instead -- that would pass even with the bug present.
     EXPECTED PRE-FIX FAILURE: ActualAccess comes back Inherited (or the DefaultEndpointAccess
     substitute), because the imported row was written under Type "Shesha.Entity".

  3. When_Import_EntityPermissions_ShouldNotDuplicateRows_TestAsync         [D1]
     After the import in test 2, query the PermissionedObject repo directly and assert there is NO row
     with Type == ShaPermissionedObjectsTypes.Entity whose Object ends in "@Get"/"@Create"/"@Update"/
     "@Delete", and exactly one row per action under Type == ShaPermissionedObjectsTypes.EntityAction.
     EXPECTED PRE-FIX FAILURE: four spurious "Shesha.Entity" rows exist.

  4. When_Import_Properties_ShouldPreserveAllFields_TestAsync               [D2]
     Give the src entity a property graph exercising every dropped field: a scalar with a non-default
     SortOrder and an explicit Source; a complex property with at least two levels of nested
     Properties; a list property with ItemsType set. Export, import, then assert each of Source,
     SortOrder, ItemsType and the full nested Properties tree survives.
     EXPECTED PRE-FIX FAILURE: Source/SortOrder null-or-default, ItemsType null, nested Properties empty.

  5. When_Import_Properties_ShouldRemoveStaleProperties_TestAsync           [D2]
     Seed dst with a property that is absent from the imported package; assert it is gone after import.
     EXPECTED PRE-FIX FAILURE: the stale property is still present.

  6. When_Import_Existing_EntityConfig_ShouldCreateNewVersion_TestAsync     [D3]
     Mirror When_Import_Existing_FormAsync: dst already has a Live IsLast EntityConfig with
     VersionNo = 10. After import assert VersionNo == 11, ParentVersion == the previous record, and
     the previous record's VersionStatus == Retired.
     EXPECTED PRE-FIX FAILURE: the existing record is mutated in place; VersionNo stays 10 and
     ParentVersion is null.

Now RUN the tests and CONFIRM THEY FAIL:

    dotnet test shesha-core/test/Shesha.Tests/Shesha.Tests.csproj --filter "FullyQualifiedName~EntityConfiguration_Tests"

Record the actual failure message for each test. Verify each failure matches the EXPECTED PRE-FIX
FAILURE noted above. If a test passes, or fails for an unrelated reason (compile error, missing
substitute, NRE in setup), the test is wrong -- fix the test, not the assertion, until it fails for
the real reason. DO NOT touch production code until every test fails for the predicted reason.

=== STEP 2: implement the fixes ===

  D1  In EntityConfigImport.MapEntityConfigAsync, change the four action blocks to
      ShaPermissionedObjectsTypes.EntityAction. Leave item.Permission as ShaPermissionedObjectsTypes.Entity.
      Consider extracting a small local helper to remove the copy-paste that caused this.

  D2  In EntityConfigImport.MapPropertiesAsync: map Source, SortOrder and ItemsType; recurse into
      src.Properties (mirroring EntityConfigExport.MapPropertyAsync); and delete target properties
      absent from the package, following ModelConfigurationManager.cs:318-323.

  D3  Enable the commented-out versioning path in ImportEntityConfigAsync (lines ~90-127). Follow
      FormConfigurationImport for the retire/create-new-version/ParentVersion sequence and for honouring
      context.ImportStatusAs. Remove the "Tempjrary" comment. If enabling this proves to require changes
      well beyond the importer -- the comment warns that other code does not yet handle versioned
      EntityConfigs -- STOP, land D1 and D2 with their tests, and report precisely what blocks D3
      rather than half-migrating the versioning model.

Optionally add an idempotent data-repair migration for rows a previous import wrote with the wrong
type (Type = 'Shesha.Entity' on objects ending in @Get/@Create/@Update/@Delete). It must not create
duplicates where a correctly-typed row already exists.

=== STEP 3: confirm the tests now PASS ===

    dotnet test shesha-core/test/Shesha.Tests/Shesha.Tests.csproj --filter "FullyQualifiedName~EntityConfiguration_Tests"
    dotnet test shesha-core/test/Shesha.Tests/Shesha.Tests.csproj --filter "FullyQualifiedName~FormConfiguration_Tests"
    dotnet test shesha-core/test/Shesha.Tests/Shesha.Tests.csproj

All EntityConfiguration_Tests must pass. FormConfiguration_Tests and the rest of the suite must show
no new failures. Report the full before/after test output.

=== REPORTING ===

In your final summary give:
  - the pre-fix failure message for each of the six tests, and the predicted reason it matched
  - the post-fix pass confirmation
  - the exact production-code diff
  - whether D3 was completed or deferred, and if deferred, exactly what blocked it
Report failures honestly. Do not describe a test as passing unless you ran it and saw it pass.

References

  • Source review against releases/0.43 @ bff870d17
  • Defect 1 introduced in 8c56003c6 ("Fix Permission objects initialization")
  • Related: there is currently no export/import mechanism at all for custom-endpoint (Shesha.WebApi / Shesha.WebApi.Action) PermissionedObject records — PermissionedObject is a plain FullAuditedEntity, not a ConfigurationItemBase, so it is invisible to the .shaconfig pipeline. Custom-endpoint access levels must be re-configured by hand in every environment. Worth a separate issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity vulnerability or hardening

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions