Search by

fucodo / edit-helper

kaystrobach

Context and permission aware edit/read/hide decisions per domain object field for Neos Flow

Package info

github.com/fucodo/edit-helper

Type:neos-package

pkg:composer/fucodo/edit-helper

Statistics

Installs: 41

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-09-15 12:01 UTC

This package is auto-updated.

Last update: 2026-09-15 12:03:13 UTC


README

fucodo/edit-helper — context and permission aware edit decisions for Neos Flow.

Answers one question in one place: may the current user see or edit this field of this object? The answer is used by Fluid view helpers (rendering), by the property mapper (server-side lock) and by controllers (403).

Installation

composer require fucodo/edit-helper

Requires Neos Flow 8.3+ and PHP 8.2+. neos/fluid-adaptor is required for the view helpers; the service, the guard and the aspect work without Fluid, so a non-Fluid application can drop that dependency and simply never load Classes/ViewHelpers.

The package is independent of fucodo/bootstrap — nothing in it emits framework-specific markup. Fluid namespaces are declared per template, so a Bootstrap-based project just writes {namespace ed=fucodo\EditHelper\ViewHelpers} (or maps a prefix to several PHP namespaces in Neos.FluidAdaptor.namespaces).

Concepts

Three access levels

Hidden  <  ReadOnly  <  Editable

FieldAccess is a hierarchical enum: Editable implies readable, Hidden implies not editable. There is no "editable but invisible" state, so a single decision covers visibility and editability; the view helpers just look at it from different angles.

FieldDecision wraps the level with an optional human-readable reason (shown as title, hint text, 403 message). Combining decisions always keeps the more restrictive one (restrictBy()).

Strategies

A strategy decides for one class:

interface EditDecisionStrategyInterface
{
    public function decide(object $subject, ?string $property, EditContext $context): FieldDecision;
}

$property === null asks for the object-level upper bound; every field decision is capped by it. EditContext carries the security context, the current ActionRequest (when known) and free-form extras.

Two base classes cover most cases:

Base class Style Use when
AbstractEditDecisionStrategy deny-list: override decideObject() and decideProperty() with a match, return null to fall back to the object level most fields are editable, some exceptions
AbstractAllowListEditDecisionStrategy allow-list: return the editable paths from editableProperties(), everything else gets fallback() (default ReadOnly); optional hiddenProperties() only a few fields may be edited

Allow-list path syntax: name, address.street, address.* (one level), address.** (any depth), *, **. A parent path (address) is implicitly editable when any listed path starts with it.

Binding a strategy to a class

Resolution order, most specific class first, walking up the inheritance chain:

  1. Settings.yamlfucodo.EditHelper.strategies.<FQCN>
  2. #[EditDecision(strategy: …)] attribute on the class (or a parent)
  3. defaultStrategy from settings (everything editable)
#[Flow\Entity]
#[EditDecision(strategy: InvoiceEditStrategy::class)]
class Invoice { … }
fucodo:
  EditHelper:
    strategies:
      'Acme\Shop\Domain\Model\Customer': 'Acme\Shop\EditDecision\CustomerEditStrategy'

The attribute of a subclass replaces the parent's strategy – it can therefore lift parent restrictions. If you want to inherit rules and lift only some, extend the parent strategy and call parent::… (see Documentation/Examples/EditDecision/DraftInvoiceEditStrategy.php).

Global rules

Applied before every class strategy and can only restrict:

fucodo:
  EditHelper:
    readOnly: false                                  # maintenance mode: everything ReadOnly
    globalStrategy: 'Acme\Shop\EditDecision\GuestReadOnlyStrategy'

Decision pipeline

global rules  ⟶  class strategy (object level)  ⟶  class strategy (field)  ⟶  [dot notation: head + sub object]
        each step can only lower the level; the minimum wins

Composites / dot notation

decide($invoice, 'billingAddress.street') is the minimum of

  • the Invoice strategy asked for billingAddress.street,
  • the Invoice strategy asked for billingAddress (so a read-only composite locks all sub fields),
  • the Address strategy asked for street on the actual Address object (resolved with its own attribute/settings).

If the sub object is null, the last step is skipped. Any depth works (a.b.c).

Usage

Fluid

{namespace ed=fucodo\EditHelper\ViewHelpers}

<!-- render only if editable -->
<ed:field.if object="{invoice}" property="amount"></ed:field.if>

<!-- render if at least readable, with then/else -->
<ed:field.if object="{invoice}" property="amount" atLeast="ReadOnly">
    <f:then></f:then><f:else></f:else>
</ed:field.if>

<!-- object level -->
<ed:field.if object="{invoice}"><f:form.submit value="Save" /></ed:field.if>

<!-- readonly/disabled + data-no-edit + title=reason for form fields -->
<f:form.textfield property="amount"
    additionalAttributes="{ed:field.attributes(object: invoice, property: 'amount')}" />
<f:form.textfield property="billingAddress.city"
    additionalAttributes="{ed:field.attributes(object: invoice, property: 'billingAddress.city', mode: 'disabled')}" />

<!-- value only when readable -->
{ed:field.value(object: invoice, property: 'amount')}
<ed:field.value object="{invoice}" property="amount" hiddenPlaceholder="">{invoice.amount -> f:format.currency()}</ed:field.value>

<!-- level name and reason -->
{ed:field.access(object: invoice, property: 'amount')}   → Hidden | ReadOnly | Editable
{ed:field.reason(object: invoice, property: 'amount')}

Server side (second lock)

Templates only hide fields; the property mapper must refuse them too.

Automatic – enable the aspect once:

fucodo:
  EditHelper:
    enforceViaAop: true

EditDecisionEnforcementAspect then, for every ActionController and every entity argument carrying __identity:

  • throws AccessDeniedException (403) when the object is not editable at all,
  • registers skipProperties() for every ReadOnly/Hidden path (including forProperty('address')->skipProperties('country')),
  • passes the current request into EditContext.

Manual – per controller:

protected function initializeUpdateAction(): void
{
    $invoice = $this->invoiceRepository->findByIdentifier($this->request->getArgument('invoice')['__identity'] ?? '');
    if ($invoice && !$this->editDecisionService->isEditable($invoice)) {
        $this->throwStatus(403);
    }
    if ($invoice) {
        $this->propertyMappingGuard->protectArgument($this->arguments['invoice'], $invoice);
    }
}

PropertyMappingGuard::protect() discovers properties via reflection and recurses into editable sub objects (maxDepth, default 2); pass an explicit path list to control it.

PHP API

$service->decide($object, 'amount')          // FieldDecision (access + reason)
$service->access($object, 'address.zip')     // FieldAccess
$service->isEditable($object)                // object level
$service->isReadable($object, 'notes')
$service->accessMap($object, ['a', 'b.c'])   // ['a' => FieldAccess, …]
$service->filterEditable($object, [...])     // editable subset
$service->createContext(['workflow' => 'review'])  // custom EditContext for decide(…, $context)

Decisions are cached per object and property for the lifetime of the service (request) when no custom context is passed; flushDecisionCache() resets it.

Configuration reference

fucodo:
  EditHelper:
    enforceViaAop: false      # activate EditDecisionEnforcementAspect
    readOnly: false           # force ReadOnly for everything
    globalStrategy: null      # strategy class applied to every subject first
    defaultStrategy: 'fucodo\EditHelper\EditDecision\DefaultEditDecisionStrategy'
    strategies: {}            # FQCN → strategy FQCN, overrides #[EditDecision]

Examples

Documentation/Examples/ contains a small Acme.Shop package:

File Shows
Domain/Invoice.php, EditDecision/InvoiceEditStrategy.php attribute binding, match style deny-list, role and state checks, read-only composite
Domain/DraftInvoice.php, EditDecision/DraftInvoiceEditStrategy.php inheritance: subclass strategy lifts parent rules explicitly
Domain/Address.php, EditDecision/AddressEditStrategy.php composite with its own strategy, used via dot notation
Domain/Customer.php, EditDecision/CustomerEditStrategy.php, Settings.yaml allow-list strategy, binding via settings, hidden fields per role
EditDecision/GuestReadOnlyStrategy.php global strategy
Controller/InvoiceController.php manual guard without AOP
Templates/Invoice/Edit.html, Show.html all view helpers, including dot notation

Tests

./bin/phpunit -c Build/BuildEssentials/PhpUnit/UnitTests.xml \
    Packages/Application/fucodo.EditHelper/Tests/Unit

79 unit tests in Tests/Unit:

Test Covers
EditDecision/FieldAccessTest level order, min(), atLeast(), fromName() incl. rejection
EditDecision/FieldDecisionTest factories, restrictBy() (never raises, keeps reason, adopts a missing one)
EditDecision/EditContextTest role/authentication delegation, immutable extras
EditDecision/AbstractEditDecisionStrategyTest object level vs. field level, null fallback, subclass lifting parent rules
EditDecision/AbstractAllowListEditDecisionStrategyTest allow list, custom fallback, hidden beats editable, all wildcard patterns (data provider)
EditDecision/EditDecisionServiceTest strategy resolution (attribute, inheritance, settings precedence, default, errors), global rules, dot notation incl. head capping and null composites, per-object caching and flush, accessMap()/filterEditable()
EditDecision/PropertyMappingGuardTest skipProperties() for flat and nested paths, property discovery with depth limit, no recursion into locked composites
ViewHelpers/Field/* rendered attributes per level and mode, level/reason output, value rendering with placeholder and tag content, condition view helper with atLeast

Fixtures in Tests/Unit/Fixtures provide models carrying real #[EditDecision] attributes, a recording strategy for call-count assertions and lightweight stand-ins for the security and reflection services, so the tests need no database and no Flow bootstrap beyond the unit test base.

Package layout

composer.json
Classes/
  Annotations/EditDecision.php                          #[EditDecision(strategy: …)]
  Aspect/EditDecisionEnforcementAspect.php              optional AOP enforcement
  EditDecision/
    FieldAccess.php, FieldDecision.php, EditContext.php
    EditDecisionStrategyInterface.php
    AbstractEditDecisionStrategy.php                     deny-list base
    AbstractAllowListEditDecisionStrategy.php            allow-list base
    DefaultEditDecisionStrategy.php
    EditDecisionService.php                              single entry point
    PropertyMappingGuard.php                             skipProperties() helper
  ViewHelpers/Field/                                     if, attributes, value, access, reason
Configuration/Settings.yaml, Objects.yaml
Documentation/Examples/
Tests/Unit/
  EditDecision/      value objects, strategies, service, guard
  ViewHelpers/Field/ view helper rendering
  Fixtures/          models with attributes, strategies, fakes
phpunit.xml.dist

Notes

  • Settings live directly under fucodo.EditHelper (no nested editDecision level); the service reads them via #[Flow\InjectConfiguration(package: 'fucodo.EditHelper')] and the AOP pointcut uses setting(fucodo.EditHelper.enforceViaAop).

  • IfViewHelper disables Fluid template compilation for itself (compile() calls $compiler->disable()) because the verdict depends on injected services.

  • EditDecisionService::classHierarchy() ignores Flow's *_Original proxy parents.

  • The aspect skips new objects (no __identity), composite identities and non-entity arguments.

  • Keep UX-driven hiding (a field that is irrelevant in a workflow state) out of the strategies; Hidden is a permission level and is enforced server-side.