Skip to content

Fix/allowedtypes picker enforcement - #59

Open
wpelczar wants to merge 2 commits into
Geta:masterfrom
wpelczar:fix/allowedtypes-picker-enforcement
Open

Fix/allowedtypes picker enforcement#59
wpelczar wants to merge 2 commits into
Geta:masterfrom
wpelczar:fix/allowedtypes-picker-enforcement

Conversation

@wpelczar

Copy link
Copy Markdown

Fixes #58.

Makes [AllowedTypes] / RestrictedTypes actually take effect in the category picker. The 2.1.0 change for #56 point 7 (PR #57) did not work in practice — #58 has the full diagnosis; the short version is two independent defects.

What was wrong

1. The attribute never reached the editor on the [Categories] path. CategoryEditorMetadata read it from the propertyAttributes handed to CategoriesAttribute.CreateDisplayMetadata, i.e. context.Attributes. CMS builds that context in ExtensibleMetadataProvider.ApplyMetadataAwareAttributes as:

var attributesForType = ModelAttributes.GetAttributesForType(metadata.GetType());
var ctx = new DisplayMetadataProviderContext(key, attributesForType);

— the attributes of the ExtendedMetadata class, not of the property. So AllowedTypesAttribute was never present, ResolveAllowedTypes silently returned its typeof(CategoryData) fallback, and the picker offered every category type. RestrictedTypes was lost the same way.

This was path-specific: CategoriesEditorDescriptor (the [UIHint] path) is an IMetadataExtender, invoked with propertyMetadata.Attributes, so it did receive the real set. The broken path is the one the 2.0.0 README and CHANGELOG recommend.

2. Delegating the allow-filter to the shell store cannot work for a nested tree. typeIdentifiers on contentstructure filters rather than marks: GetChildrenQuery restricts children to the requested type, so intermediate "container" categories are removed and every allowed category nested beneath them becomes unreachable. On a tree shaped root -> grouping category -> allowed category, the picker renders completely empty.

There is no value that satisfies both goals. Including an ancestor type so the container survives re-admits every sibling type, because the store matches via the item's whole descriptor ancestor chain (DefaultContentQueryHelper.FilterTypeIdentifierUIDescriptorRegistry.HasMatchedTypeIdentifier). Restricting the leaves and exposing the containers are mutually exclusive through that parameter.

So fixing 1 alone turns a permissive picker into an empty one. Both had to move together.

What this changes

  • Attribute resolution. New CategoryEditorMetadata.ResolvePropertyAttributes falls back to ExtendedMetadata.Attributes when the supplied set carries no AllowedTypesAttribute. That is the property's own attribute list on both wiring paths, and the same source CMS's own AllowedTypesMetadataExtender uses.
  • Enforcement moves from filtering to disabling. getChildren always requests the base category type, so the tree stays complete and navigable. Allowed/restricted types are evaluated per node by the existing Selectable endpoint, and disallowed nodes are disabled but still shown and expandable — the same "grey, don't hide" approach as the native picker (ContentTree + disableRestrictedTypes). A disallowed container is therefore still a path to the allowed categories under it.
  • Type matching stays server-side, via UIDescriptorRegistry.HasMatchedTypeIdentifier — the same call CMS makes itself. A base type matches all its subtypes, which lines up with AllowedTypesAttribute's publish-time validation, and the package does not have to model the type hierarchy in JavaScript. (Doing it in the client would need the polymorphic match too: for a typeof(...)-declared type, AllowedTypesMetadataExtender emits only the lowercased FullName, so string equality would wrongly reject subtypes.)
  • Search is now type-gated as well. It never was: SearchResultStore binds no typeIdentifiers parameter, so the values the client sent were silently discarded. Search hits now go through the same Selectable pass as tree nodes, so the request no longer sends type identifiers that cannot be honoured.

Behaviour changes worth a reviewer's opinion

  • Restricted types are now disabled rather than removed from the tree and search. This is deliberate — hiding them breaks navigation exactly the way narrowing typeIdentifiers does — but it is a visible change from 2.1.0, so flagging it explicitly.
  • Disallowed categories are visible in the picker instead of absent. That is the native picker's behaviour, and it is what keeps nested hierarchies reachable, but it is a UX decision you may want to weigh.
  • EditorConfiguration["AllowedTypes"] / ["AllowedDndTypes"] now hold type identifier strings instead of System.Type instances, matching the CMS convention for those keys. For properties that declare [AllowedTypes], CMS's own extender overwrote these anyway; for a property relying on the package default, they were previously serialised System.Type objects.

API surface

  • CategoryEditorController constructor gains a UIDescriptorRegistry parameter — binary-breaking for anything constructing it directly, though as an MVC controller it is resolved by DI.
  • Selectable(string ids)Selectable(string ids, string allowedTypes = null, string restrictedTypes = null). Source-compatible for existing callers, binary-breaking.
  • CategorySelectableResponse gains Reason (notSelectable / typeNotAllowed, exposed as CategoryEditorController.NotSelectableReason / TypeNotAllowedReason) so the picker can explain why a row is disabled. Additive.

The CHANGELOG entry is under ## [Unreleased] — move it under whichever version you plan to ship.

Tests

27 pass (17 before, 10 new):

  • ResolvePropertyAttributes — the regression guard for defect 1, covering the fallback, precedence when both sets carry the attribute, the empty case, and RestrictedTypes surviving the fallback.
  • Selectable — the type gate: allowed vs. disallowed, restricted taking precedence over allowed, IsSelectable taking precedence over the type check, no registry call when no allow-list is supplied, and unresolvable ids staying selectable.

The registry is stubbed rather than built from real UIDescriptor instances, because UIDescriptor's public constructor resolves ViewConfiguration / IContentTypeRepository through ServiceLocator. The polymorphic matching itself is CMS's contract, so the tests cover the controller's own logic: which gate wins, and which reason is reported.

Note I could not run the full-solution build locally — the sub/geta-foundation-core submodule is not part of my checkout — so the sandbox is unverified on my side. CI checks out submodules recursively, and this change touches neither the sandbox nor the submodule.

Verification

Manually verified against a large production solution currently being migrated to CMS 13 / .NET 10 under Optimizely Identity (management UI at /ui), on a category tree of ~25 category types nested behind non-selectable container categories, with 56 [Categories] properties of which 20 declare [AllowedTypes]. Tested via a locally packed build of this branch, so the module zip and buildTransitive delivery path were exercised too, not just the assembly.

Confirmed:

  • The tree shows the full hierarchy; the non-selectable container is visible and expandable, greyed with "This category is not selectable".
  • Allowed categories nested under it are selectable; categories of other types are greyed with "This category type cannot be selected for this property".
  • Searching for a disallowed category by name returns it, disabled.
  • A plain [Categories] property with no [AllowedTypes] still offers every category type, selectable.
  • [Categories] and [UIHint(CategoryUIHint.Categories)] behave identically — the point of the fix.
  • Publish-time validation still behaves as before.

Attribution

The investigation and the code were done with Claude Code. The root cause was found by decompiling the CMS 13.1.0 assemblies to trace how typeIdentifiers and the metadata pipeline actually behave, then confirmed empirically rather than inferred — the widgetSettings capture in #58 is the direct evidence. Every claim about CMS internals in #58 and in the code comments is checkable against the shipped assemblies.

Happy to reshape any of this — particularly the disable-vs-hide decision — if you would rather solve it a different way.

Wojciech Pelczar and others added 2 commits August 19, 2026 11:10
…oint 7)

The 2.1.0 change for issue Geta#56 point 7 did not take effect. Two separate
defects:

1. The property's [AllowedTypes] never reached the editor on the
   [Categories] attribute path. CMS's
   ExtensibleMetadataProvider.ApplyMetadataAwareAttributes builds the
   DisplayMetadataProviderContext from
   ModelAttributes.GetAttributesForType(metadata.GetType()) - the
   attributes of the ExtendedMetadata class, not of the property - so
   context.Attributes never contained AllowedTypesAttribute and
   ResolveAllowedTypes silently fell back to "all category types".
   RestrictedTypes was affected the same way. Attributes are now read
   from ExtendedMetadata.Attributes, which is the property's own set on
   both wiring paths.

2. Delegating the allow-filter to the shell store's typeIdentifiers
   parameter cannot work for a nested category tree. That parameter
   filters rather than marks, so narrowing it removes intermediate
   container categories and makes every allowed category nested
   underneath unreachable - for a root -> container -> allowed category
   hierarchy the picker rendered completely empty. There is no value that
   satisfies both goals either: including an ancestor type so the
   container survives re-admits every sibling type, because the store
   matches via the item's whole descriptor ancestor chain.

Allowed and restricted types are now evaluated per node by the Selectable
endpoint, and disallowed nodes are disabled but still shown and
expandable - the same "grey, don't hide" approach the native content
picker takes (ContentTree + disableRestrictedTypes). Type matching goes
through UIDescriptorRegistry.HasMatchedTypeIdentifier, so a base type
matches all its subtypes, the same semantics as AllowedTypesAttribute's
publish-time validation.

As a side effect the allow-list now also applies to search results, which
were never type-filtered at all: SearchResultStore does not bind a
typeIdentifiers parameter, so the values the client sent were silently
discarded.

Also:
- Selectable accepts allowedTypes / restrictedTypes and returns a reason
  (notSelectable / typeNotAllowed) so the picker can explain why a row is
  disabled.
- Restricted types are disabled rather than removed from the tree and
  search, for the same hierarchy reason as above.
- EditorConfiguration["AllowedTypes"] / ["AllowedDndTypes"] now hold type
  identifier strings instead of System.Type instances, matching the CMS
  convention for those keys.

Verified against a real CMS 13 / .NET 10 solution using Optimizely
Identity, with both [Categories] and [UIHint(CategoryUIHint.Categories)].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example hierarchy referenced concrete type names from the solution the
fix was verified against. Describe the roles instead — it reads better for
anyone who does not have that model in front of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[AllowedTypes] / RestrictedTypes are still not enforced by the category picker in 2.1.0

1 participant